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.

By Mickarle Wagstaff-Irons - Micky Irons

  • Level 3Building
  • 90 min
  • 6 chapters
  • Online course and PDF workbook
The Assemblyapplied / 03

Read, practise and review

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

Mickai privately records this registration and sends its team a course-start notification. It records your intention to start, not proof of attendance or completion. Our course-start privacy notice explains the details and the 30-day resume cookie.

Follow the Mickai company page on LinkedIn

You can start learning without subscribing or following. Newsletter email confirmation and provider enrolment are recorded separately.

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

01

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.

bash · 2 lines
python -m venv .venv
.venv\Scripts\python -m pip install pandas==3.0.6 matplotlib==3.11.2 numpy==2.5.3

Calling 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.

python · 39 lines
# 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.")

text · 4 lines
python make_data.py
Dry run: 306 rows. Add --write to save.
python make_data.py --write
Wrote 306 rows

Mode "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.

python · 8 lines
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())

text · 27 lines
(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: 6

str 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 min

Read the mess

List every problem in the raw file, with its evidence.

  1. Add print(df["unit"].value_counts()), print(df.groupby("unit")["time"].max()) and print(df.isna().sum()) to look.py and run it.
  2. 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.

Next chapter

Learn in your browser · Chapter 02 of 06

02

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.

Clean a copy and log every change · Table 1
ProblemDecisionWhy
Duplicate rowsDrop exact copiesDouble counting
Mixed capitalsLower-caseCHAT is chat
999 in hoursSet to missingA placeholder, not a time
Minutes and hoursConvert to hoursOne column, one unit
Missing scoresLeave missingA guess is not an answer
python · 34 lines
# 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")

text · 7 lines
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 save

Read 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 min

Check the cleaning

Confirm that the cleaned copy matches the log.

  1. Open cleaning_log.txt, then run python clean.py --write again and read the error's last line.
  2. In a new script, load tickets_clean.csv and print df.describe().round(1) and df["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.

Next chapter

Learn in your browser · Chapter 03 of 06

03

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.

python · 6 lines
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))

text · 15 lines
         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.8

Email 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 min

Two honest shares

Work out each channel's share of satisfied customers (score 4 or 5) two ways.

  1. Add sat = (df["score"] >= 4).groupby(df["channel"]).sum() to compare.py.
  2. Print sat divided by df.groupby("channel")["score"].count(), then by df.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.

Next chapter

Learn in your browser · Chapter 04 of 06

04

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.

text · 4 lines
topic,target_hours
login,4
billing,12
delivery,24

python · 10 lines
import 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))

text · 6 lines
300 {'both': 300, 'left_only': 0, 'right_only': 0}
channel
chat     0.93
email    0.80
phone    0.90
dtype: float64

how="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.

python · 22 lines
# 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 min

Break the join

See a join fail loudly, then quietly.

  1. Add billing,8 as a last line of targets.csv, run targets.py and find the pandas.errors.MergeError line. Remove the line.
  2. Change billing to Billing, run again and note the _merge counts. 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.

Next chapter

Learn in your browser · Chapter 05 of 06

05

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.

python · 26 lines
# 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}")

text · 5 lines
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.10

Negative 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 min

Change the seed

See which results depend on chance.

  1. Change seed=1 to seed=2 in boot.py, run it and compare each line.
  2. 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.'

Next chapter

Learn in your browser · Chapter 06 of 06

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.

python · 15 lines
# 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))

text · 1 line
104 2.1

It 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:

text · 5 lines
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:

python · 6 lines
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 min

Review an AI draft

Find the draft's problems, then confirm them.

  1. List every problem you can see.
  2. Save it as draft.py in data-practice, run it and read the error's last line.
  3. Print len(df) before and after dropna().

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.

Assessment options

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.

  1. 01
    Start with a question, then meet the data

    Write the question down, set up, make the practice data and look before changing anything.

    Read here · 1 exercise
  2. 02
    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.

    Read here · 1 exercise
  3. 03
    Group, compare and choose the denominator

    Split rows into groups, summarise each one and compare like with like.

    Read here · 1 exercise
  4. 04
    Join a lookup table and chart it over time

    Bring in a second table safely, then watch the data month by month.

    Read here · 1 exercise
  5. 05
    How sure can you be?

    A difference in your sample is not automatically a difference in the world.

    Read here · 1 exercise
  6. 06
    Make it reproducible, and use AI safely

    An analysis nobody can rerun or check is only a story.

    Read here · 1 exercise

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.

We privately record your name, email and request IP for this download. Read our privacy notice and retention policy.

Visit Mickai on LinkedIn

Newsletter confirmation does not affect your download. Following Mickai on LinkedIn is optional; this site does not verify followers.

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 questions

Learn 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) or datetime64 (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.

  1. What's new in 3.0.0 (default string data type)pandas 3.0.6 documentation
  2. NumPy 2.5.0 release notes (drops Python 3.11; supports 3.12 to 3.14)NumPy
  3. pandas.read_csvpandas 3.0.6 documentation
  4. pandas.DataFrame.to_csv (mode 'x')pandas 3.0.6 documentation
  5. pandas.DataFrame.describepandas 3.0.6 documentation
  6. pandas.Series.value_countspandas 3.0.6 documentation
  7. pandas.DataFrame.duplicatedpandas 3.0.6 documentation
  8. pandas.DataFrame.drop_duplicatespandas 3.0.6 documentation
  9. pandas.DataFrame.dropnapandas 3.0.6 documentation
  10. Working with missing datapandas 3.0.6 documentation
  11. pandas.Series.medianpandas 3.0.6 documentation
  12. Group by: split-apply-combine (including named aggregation)pandas 3.0.6 documentation
  13. pandas.crosstabpandas 3.0.6 documentation
  14. pandas.pivot_tablepandas 3.0.6 documentation
  15. pandas.DataFrame.mergepandas 3.0.6 documentation
  16. pandas.Series.dt.to_periodpandas 3.0.6 documentation
  17. pandas.Series.corrpandas 3.0.6 documentation
  18. pandas.DataFrame.plotpandas 3.0.6 documentation
  19. matplotlib.figure.Figure.savefigMatplotlib 3.11.2 documentation
  20. matplotlib.axes.Axes.set_ylimMatplotlib 3.11.2 documentation
  21. matplotlib.pyplot.subplotsMatplotlib 3.11.2 documentation
  22. matplotlib.figure.Figure.tight_layoutMatplotlib 3.11.2 documentation
  23. venv: creation of virtual environmentsPython Software Foundation
  24. random: generate pseudo-random numbers (notes on reproducibility)Python Software Foundation
  25. statistics: mathematical statistics functionsPython Software Foundation
  26. csv: CSV file reading and writingPython Software Foundation
  27. Built-in functions: open() and mode 'x'Python Software Foundation
  28. Modules: the module search path (The Python Tutorial)Python Software Foundation
  29. 1.1.1 What is EDA?NIST/SEMATECH e-Handbook of Statistical Methods
  30. 1.3.5.1 Measures of locationNIST/SEMATECH e-Handbook of Statistical Methods
  31. Dataplot reference manual: CORRELATIONNational Institute of Standards and Technology
  32. 1.3.3.26 Scatter plot (causality is not proved by association)NIST/SEMATECH e-Handbook of Statistical Methods
  33. 1.3.3.4 Bootstrap plotNIST/SEMATECH e-Handbook of Statistical Methods
  34. 1.3.5.2 Confidence limits for the meanNIST/SEMATECH e-Handbook of Statistical Methods
  35. 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