applied · Level 3
Data analysis with Python: investigate and explain a dataset
Load, inspect, clean, group, join and chart a small invented dataset with pandas, then say honestly how sure you are.

Your next step
Start this course online
Register your start, then work through the lessons and exercises at your own pace. You can download the PDF separately. Where a scored exam is available, you will find it after the lessons.
Register or resume your course
Start with the essentials
The short answer
Data analysis with Python starts with a written question. Load the table with pandas, inspect what is really there, and clean a copy in a script that logs every change. Compare like with like, check what each share divides by, and put an interval around your result. Recompute key numbers a second way, and never treat a correlation as proof of a cause.
What you will learn
- You will be able to load a CSV file with pandas and inspect its shape, types, summaries and category counts.
- You will be able to clean a copy of a dataset with a script that logs every change and never edits the original.
- You will be able to compare groups fairly, join a lookup table safely and choose an honest denominator.
- You will be able to save a chart with matplotlib, read it critically and explain why correlation is not causation.
- You will be able to put a bootstrap interval around a result and say what your sample size allows you to claim.
- You will be able to brief an AI assistant without sharing real data and check the analysis code it returns.
Who it is for
Anyone who can run a Python script and wants to answer a real question from a table of data, then explain the answer honestly. You need a terminal, Python 3.12 or later and permission to install packages. No statistics background is needed.
Before you start
- Comfort running a Python script from a terminal, and with variables, loops, functions, files and CSV. The Python: from first script to a useful automation workbook covers all of this.
Learn in your browser · Chapter 01 of 06
Start with a question, then meet the data
Write the question down, set up, make the practice data and look before changing anything.
Data analysis means answering one clear question from a table and saying how sure you are. The NIST/SEMATECH e-Handbook of Statistical Methods calls exploratory data analysis an approach, not a set of techniques: let the data show its structure first.
The question. A manager says chat beats email. Yours: are chat tickets resolved faster than email tickets, like for like? Measure hours to resolution for invented tickets opened 1 January to 14 June 2026. A written question stops you drifting.
Set up. Make a folder, data-practice, open a terminal in it and create a virtual environment (a private set of packages) with pinned versions. On macOS and Linux, type python3 in line one and .venv/bin/python in line two.
python -m venv .venv
.venv\Scripts\python -m pip install pandas==3.0.6 matplotlib==3.11.2 numpy==2.5.3Calling the environment's Python directly needs no activation, says the venv documentation, so from here python means .venv\Scripts\python (or .venv/bin/python). These pins need Python 3.12 or later. All output here came from Python 3.12.10 on Windows 11; other Python versions may invent different data, and the macOS and Linux commands were not run.
Save make_data.py. Its fixed seed (the random generator's starting value) makes the same 300 invented tickets on every run, messy on purpose.
# make_data.py: invent messy support tickets (no real people)
import random
import sys
from datetime import date, timedelta
from pathlib import Path
import pandas as pd
rng = random.Random(2026)
HOURS = {"login": 2, "billing": 6, "delivery": 16}
CHAT = {"login": 0.6, "billing": 0.3, "delivery": 0.12} # the topic sways the channel
OTHER = {"login": ["email", "phone"], "billing": ["email", "phone", "phone"],
"delivery": ["email", "email", "email", "phone"]}
rows = []
for i in range(1, 301):
day = rng.randrange(165) # 1 January to 14 June 2026
topic = rng.choice(["login", "login", "billing", "delivery"])
chat = rng.random() < CHAT[topic] * (0.4 + day / 165) # chat grows over time
channel = "chat" if chat else rng.choice(OTHER[topic])
hours = HOURS[topic] * rng.lognormvariate(0, 0.6) * (0.8 if chat else 1)
score = round(min(5, max(1, 5.2 - hours / 6 + rng.gauss(0, 0.8))))
rows.append({
"ticket": f"T{i:04d}",
"opened": str(date(2026, 1, 1) + timedelta(days=day)),
"channel": channel.upper() if rng.random() < 0.1 else channel,
"topic": topic,
"time": round(hours * 60) if channel == "phone" else round(hours, 1),
"unit": "min" if channel == "phone" else "h",
"score": None if rng.random() < 0.35 else score,
})
rows[57]["time"] = 999 # a placeholder someone typed
rows += rows[100:106] # six rows exported twice
df = pd.DataFrame(rows)
if "--write" in sys.argv:
df.to_csv(Path(__file__).parent / "tickets_raw.csv", index=False, mode="x")
print(f"Wrote {len(df)} rows")
else:
print(f"Dry run: {len(df)} rows. Add --write to save.")python make_data.py
Dry run: 306 rows. Add --write to save.
python make_data.py --write
Wrote 306 rowsMode "x" makes a repeat --write fail with FileExistsError rather than overwrite. Now save look.py. Never name a script after a module such as pandas.py: Python searches the script's folder first and would import yours.
import pandas as pd
df = pd.read_csv("tickets_raw.csv")
print(df.shape)
print(df.dtypes)
print(df["channel"].value_counts())
print(df.describe().round(1))
print("duplicate rows:", df.duplicated().sum())(306, 7)
ticket str
opened str
channel str
topic str
time float64
unit str
score float64
dtype: object
channel
chat 93
phone 90
email 88
CHAT 14
EMAIL 11
PHONE 10
Name: count, dtype: int64
time score
count 306.0 219.0
mean 156.1 3.8
std 343.6 1.3
min 0.3 1.0
25% 2.2 3.0
50% 9.6 4.0
75% 116.8 5.0
max 2659.0 5.0
duplicate rows: 6str is pandas 3's text type, so opened is not yet a date. Note six labels for three channels, and a mean time (156.1) far above the median (the 50% row, 9.6).
Try it yourself · Activity 01
10 minRead the mess
List every problem in the raw file, with its evidence.
- Add
print(df["unit"].value_counts()),print(df.groupby("unit")["time"].max())andprint(df.isna().sum())tolook.pyand run it. - Write each problem beside the output that shows it.
Which problem would distort your answer most?
Worked answer
Six duplicate rows; six labels for three channels; two units in one column (206 h, 100 min); a top hours value of 999.0 (about 42 days, suspiciously round), while the top minutes value, 2659.0, is about 44 hours; 87 missing scores, no other gaps; opened as text. Mixed units distort most, inflating every average.
Learn in your browser · Chapter 02 of 06
Clean a copy and log every change
Cleaning decides what the data means: do it in a script, on a copy, with every decision logged.
Never edit the raw file. Change data only in code, log each change with a count, and set impossible values to missing rather than guess.
Scroll sideways to see every column.
| Problem | Decision | Why |
|---|---|---|
| Duplicate rows | Drop exact copies | Double counting |
| Mixed capitals | Lower-case | CHAT is chat |
| 999 in hours | Set to missing | A placeholder, not a time |
| Minutes and hours | Convert to hours | One column, one unit |
| Missing scores | Leave missing | A guess is not an answer |
# clean.py: never edits the raw file; writes a cleaned copy and a log
import sys
from pathlib import Path
import pandas as pd
HERE = Path(__file__).parent
df = pd.read_csv(HERE / "tickets_raw.csv", parse_dates=["opened"])
log = [f"read {len(df)} rows"]
n = len(df)
df = df.drop_duplicates()
log.append(f"dropped {n - len(df)} duplicate rows")
df["channel"] = df["channel"].str.lower()
log.append("lower-cased channel")
bad = df["time"] == 999
df.loc[bad, "time"] = None
log.append(f"set {bad.sum()} placeholder 999 to missing")
mins = df["unit"] == "min"
df.loc[mins, "time"] = df.loc[mins, "time"] / 60
df = df.drop(columns="unit").rename(columns={"time": "hours"})
log.append(f"converted {mins.sum()} minute times to hours")
log.append(f"kept {len(df)} rows, {df['score'].isna().sum()} without a score")
print("\n".join(log))
if "--write" in sys.argv:
df.to_csv(HERE / "tickets_clean.csv", index=False, mode="x")
with open(HERE / "cleaning_log.txt", "x", encoding="utf-8") as f:
f.write("\n".join(log) + "\n")
else:
print("Dry run: add --write to save")read 306 rows
dropped 6 duplicate rows
lower-cased channel
set 1 placeholder 999 to missing
converted 99 minute times to hours
kept 300 rows, 85 without a score
Dry run: add --write to saveRead the dry run, then add --write. drop_duplicates compares every column and keeps the first copy; NaN (not a number) marks missing values. The 999 check runs first, on values as typed; after conversion, a 999 typed in minutes would slip through. Treating it as missing is a judgement: report it.
Try it yourself · Activity 02
10 minCheck the cleaning
Confirm that the cleaned copy matches the log.
- Open
cleaning_log.txt, then runpython clean.py --writeagain and read the error's last line. - In a new script, load
tickets_clean.csvand printdf.describe().round(1)anddf["channel"].value_counts().
Which decision would you most want a colleague to question?
Worked answer
The repeat ends FileExistsError: [Errno 17] File exists: plus the path; nothing is overwritten. describe counts 299 hours (median 3.7, maximum 78.1) and 215 scores; channels are chat 104, phone 99 and email 97, the 300 rows logged.
Learn in your browser · Chapter 03 of 06
Group, compare and choose the denominator
Split rows into groups, summarise each one and compare like with like.
groupby splits rows into groups and summarises each. A crosstab counts rows for each pair of categories; a pivot table puts a summary in each cell. Save this as compare.py.
import pandas as pd
df = pd.read_csv("tickets_clean.csv")
print(df.groupby("channel")["hours"].agg(["size", "count", "median", "mean"]).round(1))
print(pd.crosstab(df["topic"], df["channel"]))
print(df.pivot_table(index="topic", columns="channel", values="hours", aggfunc="median").round(1)) size count median mean
channel
chat 104 104 2.1 4.0
email 97 96 7.4 11.7
phone 99 99 4.0 7.5
channel chat email phone
topic
billing 19 16 33
delivery 11 50 24
login 74 31 42
channel chat email phone
topic
billing 4.8 6.0 5.9
delivery 12.9 14.4 16.2
login 1.6 1.9 1.8Email has 97 tickets (size) but 96 times (count). Each median (middle value) is below its mean because, as the NIST handbook explains, skew pulls the mean towards the long tail. The median better describes a typical ticket; the mean still matters for total workload.
Chat's median is 2.1 hours against email's 7.4. Case closed? No: 74 of chat's 104 tickets are quick logins, but 50 of email's 97 are slow deliveries. Within topics the gaps shrink: 1.6 against 1.9 (login), 4.8 against 6.0 (billing), 12.9 against 14.4 (delivery). Topic sways both the channel and the time: a confounder.
Denominators. A share is only as honest as its denominator. The pandas documentation notes that comparisons with NaN are always False, so df["score"] >= 4 counts an unanswered survey as not satisfied. Dividing by answers or by all tickets tells different stories.
Try it yourself · Activity 03
10 minTwo honest shares
Work out each channel's share of satisfied customers (score 4 or 5) two ways.
- Add
sat = (df["score"] >= 4).groupby(df["channel"]).sum()tocompare.py. - Print
satdivided bydf.groupby("channel")["score"].count(), then bydf.groupby("channel").size(), each with.round(2).
Which share would a chat enthusiast quote?
Worked answer
Satisfied: chat 63, email 42, phone 45. Of those who answered: 0.84, 0.59, 0.65. Of all tickets: 0.61, 0.43, 0.45. Report the first as 'of customers who answered', beside the response rates (0.72, 0.73, 0.70), because people who answer surveys may differ from those who do not.
Learn in your browser · Chapter 04 of 06
Join a lookup table and chart it over time
Bring in a second table safely, then watch the data month by month.
A join (pandas says merge) matches rows of two tables on a key column. Save these invented targets as targets.csv in a plain text editor, then save targets.py.
topic,target_hours
login,4
billing,12
delivery,24import pandas as pd
df = pd.read_csv("tickets_clean.csv")
targets = pd.read_csv("targets.csv")
df = df.merge(targets, on="topic", how="left", validate="many_to_one", indicator=True)
print(len(df), df["_merge"].value_counts().to_dict())
timed = df[df["hours"].notna()]
met = timed["hours"] <= timed["target_hours"]
print(met.groupby(timed["channel"]).mean().round(2))300 {'both': 300, 'left_only': 0, 'right_only': 0}
channel
chat 0.93
email 0.80
phone 0.90
dtype: float64how="left" keeps every ticket; the default inner join silently drops unmatched ones. validate="many_to_one" stops if a topic appears twice in the lookup, which would copy tickets. indicator=True adds _merge, proving all 300 matched. Tickets without a time are excluded: comparing NaN gives False, a false miss.
Time. With opened read as a date you can count by month. chart.py prints the counts and draws a line per channel with matplotlib; only --write saves the image, and mode "xb" never overwrites it.
# chart.py: tickets opened per month, one line per channel
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
HERE = Path(__file__).parent
df = pd.read_csv(HERE / "tickets_clean.csv", parse_dates=["opened"])
monthly = pd.crosstab(df["opened"].dt.to_period("M"), df["channel"])
print(monthly)
fig, ax = plt.subplots(figsize=(7, 4))
monthly.plot(ax=ax, marker="o")
ax.set_ylim(bottom=0)
ax.set_title("Tickets per month by channel (invented data)")
ax.set_xlabel("Month opened (June holds 1 to 14 June only)")
ax.set_ylabel("Tickets opened")
fig.tight_layout()
if "--write" in sys.argv:
with open(HERE / "tickets_by_month.png", "xb") as f: # never overwrite
fig.savefig(f, format="png", dpi=150)Read a chart before you believe it. This y-axis starts at zero, so heights compare fairly. June seems to collapse but holds only 14 days, as the label warns. Chat climbs from 8 tickets in January to 26 in May, so time could also confound a channel comparison. Distrust twin y-axes, hand-picked date ranges and percentages without counts.
Try it yourself · Activity 04
10 minBreak the join
See a join fail loudly, then quietly.
- Add
billing,8as a last line oftargets.csv, runtargets.pyand find thepandas.errors.MergeErrorline. Remove the line. - Change
billingtoBilling, run again and note the_mergecounts. Change it back.
Which failure would you have missed without the checks?
Worked answer
The duplicate gives pandas.errors.MergeError: Merge keys are not unique in right dataset; not a many-to-one merge; without validate, the 68 billing tickets would each appear twice (368 rows). Billing runs, but _merge shows 232 both and 68 left_only, and the shares fall to 0.75, 0.66 and 0.61: a wrong key fails quietly.
Learn in your browser · Chapter 05 of 06
How sure can you be?
A difference in your sample is not automatically a difference in the world.
Correlation is not causation. A correlation measures a straight-line link between two columns: 1 or -1 when perfect (rising together, or one falling as the other rises), 0 when there is none. Hours and satisfaction correlate at -0.8. Does waiting cause unhappiness? Here, yes: make_data.py builds the score from the hours. Real data has no such script, and the NIST handbook is plain: association does not imply causality.
Uncertainty. A bootstrap, as the NIST handbook describes it, resamples the data with replacement (a row may be picked twice or not at all), recomputes the statistic many times and reads an interval off the sorted results. NIST calls 500 repeats usually enough; boot.py uses 1,000. It drops only rows without a time, as the statistics documentation says to strip NaN first.
# boot.py: how sure can we be about a gap between two medians?
import random
import statistics
import pandas as pd
def interval(a, b, seed=1):
"""95% interval for median(a) - median(b) from 1,000 resamples."""
rng = random.Random(seed)
gaps = sorted(
statistics.median(rng.choices(a, k=len(a))) - statistics.median(rng.choices(b, k=len(b)))
for _ in range(1000)
)
return gaps[24], gaps[974] # the 25th and 975th
df = pd.read_csv("tickets_clean.csv").dropna(subset=["hours"])
print("hours v score:", round(df["hours"].corr(df["score"]), 2))
for topic in [None, "login", "billing", "delivery"]:
part = df if topic is None else df[df["topic"] == topic]
chat = part.loc[part["channel"] == "chat", "hours"].tolist()
email = part.loc[part["channel"] == "email", "hours"].tolist()
gap = statistics.median(chat) - statistics.median(email)
low, high = interval(chat, email)
print(f"{topic or 'all'}: n {len(chat)}/{len(email)}, gap {gap:.2f}, interval {low:.2f} to {high:.2f}")hours v score: -0.8
all: n 104/96, gap -5.35, interval -8.95 to -3.90
login: n 74/30, gap -0.30, interval -1.30 to 0.10
billing: n 19/16, gap -1.15, interval -3.70 to 0.60
delivery: n 11/50, gap -1.45, interval -8.60 to 13.10Negative gaps mean chat is faster, using unrounded medians (email's 7.4 is really 7.45). Overall, chat's median is 5.35 hours shorter, and the interval is far from zero. Within each topic, every interval crosses zero, and delivery's is very wide because only 11 chat tickets sit behind it. Yet make_data.py makes chat 20% faster in every topic (the 0.8): at these sample sizes the data cannot confirm it.
Try it yourself · Activity 05
10 minChange the seed
See which results depend on chance.
- Change
seed=1toseed=2inboot.py, run it and compare each line. - Answer the chapter 1 question in two sentences.
What would you need before telling the manager chat is faster?
Worked answer
Seed 2 gives -8.75 to -3.75, -1.40 to 0.10, -3.70 to 0.55 and -8.60 to 13.90: the gaps come from the data and stay fixed, the edges move a little, and every conclusion holds. Answer: 'Chat's median was 5.35 hours shorter than email's, mostly because chat handles more quick login problems. Within a topic, any gap is small and too uncertain to confirm at this sample size.'
Learn in your browser · Chapter 06 of 06
Make it reproducible, and use AI safely
An analysis nobody can rerun or check is only a story.
Reproducible means someone else can rebuild every number from the raw file: pinned versions, an untouched raw file, a logged cleaning script, one run order. Keep the generated file too: the random documentation promises repeatable sequences across Python versions only for random(), not methods such as choice.
Recompute a second way. check.py rebuilds chat's median from the raw file with only the standard library and its own cleaning rules.
# check.py: recompute the chat median from the RAW file, without pandas
import csv
import statistics
seen, hours = set(), []
with open("tickets_raw.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
key = tuple(row.values())
if key in seen:
continue
seen.add(key)
t = float(row["time"])
if row["channel"].lower() == "chat" and t != 999:
hours.append(t / 60 if row["unit"] == "min" else t)
print(len(hours), round(statistics.median(hours), 1))104 2.1It matches compare.py: 104 tickets, 2.1 hours. When independent routes disagree, at least one has a bug.
AI assistants. A chat or editor assistant drafts pandas code fast and can be confidently wrong. Give it the schema and an invented row, never real data:
Columns: ticket (text), opened (date), channel (chat, email, phone),
topic (text), hours (number, some missing), score (1 to 5, many missing).
Invented row: T0001,2026-01-31,email,billing,17.6,1.0
Using pandas 3.0.6, print the median hours per channel and topic.
Do not drop or fill missing values. Print the row count after each step.Removing names is not enough: the ICO's guidance, under review in September 2026, says pseudonymised data (identifiers swapped for reference numbers) is still personal data under the UK GDPR. Follow your organisation's rules.
Check what comes back: row counts around dropna() and joins, column names against df.columns, the counts behind shares, and any fillna(0) or file writing. This draft claims each channel's on-time share:
import pandas as pd
df = pd.read_csv("tickets_clean.csv").dropna()
targets = pd.read_csv("targets.csv")
df = df.merge(targets, on="channel")
print(df.groupby("channel")["resolved_on_time"].mean())Try it yourself · Activity 06
15 minReview an AI draft
Find the draft's problems, then confirm them.
- List every problem you can see.
- Save it as
draft.pyindata-practice, run it and read the error's last line. - Print
len(df)before and afterdropna().
Which problem would survive a confident explanation?
Worked answer
dropna() silently drops any row with a missing value: 300 rows become 214, mostly for missing scores, which are irrelevant to timing. The join uses channel but targets are per topic, so it fails with KeyError: 'channel'. resolved_on_time is an invented column. With the right key, the default inner join would still drop unmatched tickets silently.
Keep learning
The complete workbook
This workbook follows one question through one invented, deliberately messy table of support tickets. You will inspect, clean, group, join and chart it with pandas and matplotlib, measure your uncertainty with a simple bootstrap and make the analysis reproducible. It ends with safe habits for using an AI assistant to write analysis code.
- 01Start with a question, then meet the dataRead here · 1 exercise
Write the question down, set up, make the practice data and look before changing anything.
- 02Clean a copy and log every changeRead here · 1 exercise
Cleaning decides what the data means: do it in a script, on a copy, with every decision logged.
- 03Group, compare and choose the denominatorRead here · 1 exercise
Split rows into groups, summarise each one and compare like with like.
- 04Join a lookup table and chart it over timeRead here · 1 exercise
Bring in a second table safely, then watch the data month by month.
- 05How sure can you be?Read here · 1 exercise
A difference in your sample is not automatically a difference in the world.
- 06Make it reproducible, and use AI safelyRead here · 1 exercise
An analysis nobody can rerun or check is only a story.
Also inside: a 10-point checklist, a glossary of 12 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.
The PDF is free. We ask for your name and email before downloading. Read the privacy notice at the download form. Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.
Get your free PDF workbook
Enter your name and email, then download the complete workbook. This form also works without JavaScript.
Test yourself
Questions and answers
Do I need statistics training to analyse data with Python?
Not to start. Counting, grouping, comparing medians and reading a chart need care rather than advanced maths. You do need honesty about limits: check sample sizes, show uncertainty and never treat a correlation as a cause. For decisions that matter, ask someone with statistical training to review your method.
Why pandas rather than the csv module?
The csv module is fine for reading rows and for small checks, as check.py shows. pandas adds typed columns, missing-value handling, grouping, joins, dates and plotting in a few lines. Using both is a good habit: pandas for the analysis, the standard library for an independent second calculation.
Which versions does this workbook use?
Python 3.12.10 on Windows 11 with pandas 3.0.6, matplotlib 3.11.2 and numpy 2.5.3, installed in a fresh virtual environment. These pins need Python 3.12 or later. Pin the same versions to reproduce every printed number: another Python version may invent different data, and the macOS and Linux commands were not run.
Should I delete rows with missing values?
Not by default. dropna() removes a row if any column is missing, so here it would throw away 86 of 300 tickets, mostly for a missing score that has nothing to do with timing. Drop only what your question requires, name the column, and log how many rows you lost and why.
When should I use the median instead of the mean?
When the data is skewed, such as times, prices or incomes, where a few very large values pull the mean up. The median is the middle value and resists that pull. Report the counts behind either one, and consider showing both.
Why did chat look much faster than it really was?
Chat handled a larger share of quick login problems, while email handled more slow delivery problems. Topic affected both which channel was used and how long a ticket took. Comparing within each topic removed most of the gap. Always ask what else differs between the groups you compare.
What does a 95% bootstrap interval tell me?
It shows how much your result might vary between samples of the same size. If it sits well away from zero, the gap is unlikely to be chance alone. If it crosses zero, the data cannot confirm the direction. It cannot fix a biased sample or a confounder.
Can I paste my company's data into an AI assistant?
Do not paste real data. Give the assistant the column names, types and a few invented rows instead. The ICO's guidance, under review in 2026, says pseudonymised data, with names replaced by reference numbers, is still personal data under the UK GDPR, so follow your organisation's rules before any real data goes near any tool.
How do I check AI-written analysis code?
Read it before you run it. Look for silent dropna() calls, inner joins, joins on the wrong key, invented column names and shares with no counts. Print row counts after each step. Then recompute one key number a different way, and make sure the two routes agree.
How do I make my analysis reproducible?
Pin package versions, seed anything random, keep the raw file untouched, and let scripts write a cleaned copy and a log. Save the generated files too: Python's documentation promises repeatable output across versions only for random() itself, not for methods such as choice or gauss.
Write as you go
My notes
Use this scratchpad for your own notes, working or answers as you read. Notes are saved in this browser on this device. Downloading makes a local copy; it does not send your notes to Mickai.
Consolidate your learning
Review your work
Work through the exercises and questions, then compare your reasoning with the worked answers. A scored exam and verifiable certificate are not yet available for this course.
Review the questionsLearn the language
Key terms
- DataFrame
- pandas' table: named columns of equal length, each with one data type.
- dtype
- The data type of a column, such as
str(text),float64(decimal numbers) ordatetime64(dates and times). - Exploratory data analysis
- Looking at data with summaries and charts to find its structure and problems before assuming anything about it.
- Missing value
- A value that is absent, shown by pandas as
NaN. Summaries skip it by default, so always check how many there are. - Median
- The middle value once the values are sorted. Long tails pull it far less than they pull the mean.
- groupby
- Split rows into groups by a column, summarise each group, then combine the results.
6 of the workbook's 12 terms. The complete glossary is in the workbook.
Follow the evidence
Sources and checks
Facts last checked: .
Examples in this workbook were run on: Python 3.12.10 on Windows 11, in a fresh virtual environment with pandas 3.0.6, matplotlib 3.11.2 and numpy 2.5.3 (installed by pip 25.0.1) (2026-09-26).
These workbooks use AI assistance. See how the workbooks are made.
- What's new in 3.0.0 (default string data type)pandas 3.0.6 documentation
- NumPy 2.5.0 release notes (drops Python 3.11; supports 3.12 to 3.14)NumPy
- pandas.read_csvpandas 3.0.6 documentation
- pandas.DataFrame.to_csv (mode 'x')pandas 3.0.6 documentation
- pandas.DataFrame.describepandas 3.0.6 documentation
- pandas.Series.value_countspandas 3.0.6 documentation
- pandas.DataFrame.duplicatedpandas 3.0.6 documentation
- pandas.DataFrame.drop_duplicatespandas 3.0.6 documentation
- pandas.DataFrame.dropnapandas 3.0.6 documentation
- Working with missing datapandas 3.0.6 documentation
- pandas.Series.medianpandas 3.0.6 documentation
- Group by: split-apply-combine (including named aggregation)pandas 3.0.6 documentation
- pandas.crosstabpandas 3.0.6 documentation
- pandas.pivot_tablepandas 3.0.6 documentation
- pandas.DataFrame.mergepandas 3.0.6 documentation
- pandas.Series.dt.to_periodpandas 3.0.6 documentation
- pandas.Series.corrpandas 3.0.6 documentation
- pandas.DataFrame.plotpandas 3.0.6 documentation
- matplotlib.figure.Figure.savefigMatplotlib 3.11.2 documentation
- matplotlib.axes.Axes.set_ylimMatplotlib 3.11.2 documentation
- matplotlib.pyplot.subplotsMatplotlib 3.11.2 documentation
- matplotlib.figure.Figure.tight_layoutMatplotlib 3.11.2 documentation
- venv: creation of virtual environmentsPython Software Foundation
- random: generate pseudo-random numbers (notes on reproducibility)Python Software Foundation
- statistics: mathematical statistics functionsPython Software Foundation
- csv: CSV file reading and writingPython Software Foundation
- Built-in functions: open() and mode 'x'Python Software Foundation
- Modules: the module search path (The Python Tutorial)Python Software Foundation
- 1.1.1 What is EDA?NIST/SEMATECH e-Handbook of Statistical Methods
- 1.3.5.1 Measures of locationNIST/SEMATECH e-Handbook of Statistical Methods
- Dataplot reference manual: CORRELATIONNational Institute of Standards and Technology
- 1.3.3.26 Scatter plot (causality is not proved by association)NIST/SEMATECH e-Handbook of Statistical Methods
- 1.3.3.4 Bootstrap plotNIST/SEMATECH e-Handbook of Statistical Methods
- 1.3.5.2 Confidence limits for the meanNIST/SEMATECH e-Handbook of Statistical Methods
- What is personal data?Information Commissioner's Office
Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 26 September 2026.
NextKeep going
Where to go next
Recommended for you
How to evaluate a new frontier model the day it lands
A ten-step method for judging a new model: primary sources, licence, architecture, hardware, benchmarks, your own tests, safety, jurisdiction and a decision record.
Recommended for you
RAG and knowledge bases, step by step
Build a knowledge base a model can answer from: collect, clean, chunk, embed, retrieve, cite and evaluate, with security basics and a hands-on exercise using five short documents.