Session 3 lab: Clean it, check it, log it¶
Three files from three sources, each with its own problems: repeated football matches, a usage file with most of its dates missing, and a retail workbook whose sheets must be joined without losing or doubling anything.
Open this notebook in Google Colab with the button above, then run each cell from top to bottom with Shift + Enter. Save your own copy with File → Save a copy in Drive.
The business decision¶
The head of analytics says:
"We have been handed three files for next quarter's reports. Before anyone builds a chart on them: what is wrong with each one, what did you fix, and can I see the proof?"
Your job today is not to find insights. It is to make each file fit for use, show the evidence, and say clearly what still cannot be trusted.
About the data
- World Cup results: compiled for Sports Viz Sundays 2018 and published in Tableau's sample-data catalog. We download it from that source for learning; we do not redistribute it, and the course does not claim a licence for it.
- Mobile OS usage: from Statcounter Global Stats (licence CC BY-SA 3.0), via Tableau's sample-data catalog. Each percentage is a share of page views on the websites Statcounter tracks, not a share of devices sold or of people.
- Superstore: Tableau's sample for a fictitious company. Sales and Profit are in source monetary units; no currency is stated.
How this lab works¶
| Part | What you do | Tasks |
|---|---|---|
| Guided practice | Follow along step by step, everyone together | G1–G5 |
| Independent tasks | Same skills, new questions, on your own or in pairs | I1–I6 |
| Challenge | Harder reasoning and debugging | C1–C2 |
Each task has a level:
- Core: everyone completes it; it is enough for your portfolio.
- Stretch: for when you finish the core tasks early.
- Challenge: optional, harder reasoning.
What you will be able to do¶
- Profile a new file: size, types, missing values and duplicates.
- Find exact duplicates and confirm the fix against an independent total.
- Detect stray spaces, broken characters and inconsistent names.
- Diagnose missing dates and choose a subset you can defend.
- Join three tables with
validate=,indicator=Trueand row-count checks. - Keep a cleaning log with before and after counts.
Evidence you keep: your cleaning log, the duplicate check against official totals, your Mobile OS decision with its reason, the Superstore join checks and your fix for the debugging challenge.
Before you start, work through the Session 3 lecture.
Guided practice¶
G1 (Core): Load the three files¶
The first cell is the course setup cell you know from Session 2: it downloads the Superstore workbook and checks it. The second cell defines fetch, which downloads a file into memory and checks its fingerprint, and then loads the other two files. Nothing is saved over the originals: every change today happens to a copy in Python.
# Course data setup. Run this cell first; you do not need to understand it yet.
# It downloads the Superstore sample from Tableau's website, checks it is the
# exact version this course was written for, and prepares the order-lines table.
import hashlib
import os
import urllib.request
import matplotlib.pyplot as plt
import pandas as pd
SOURCE_URL = "https://public.tableau.com/app/sample-data/sample_-_superstore.xls"
EXPECTED_SHA256 = "5045ed4fec49cae2cc0dfc5ab0d204ba09efa767fe00c996822499e8cc42fbbd"
SOURCE_FILE = os.environ.get("COURSE_XLS_PATH", "sample_-_superstore.xls")
try:
import xlrd # noqa: F401 (needed to read .xls files)
except ImportError:
import subprocess
import sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "xlrd"])
if not os.path.exists(SOURCE_FILE):
urllib.request.urlretrieve(SOURCE_URL, SOURCE_FILE)
with open(SOURCE_FILE, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
if digest != EXPECTED_SHA256:
print("WARNING: Tableau has changed this sample file since the course was written.")
print("Your numbers will differ from the lesson. Ask your facilitator for the course copy.")
def load_orders():
"""Return one row per order line, orders placed 2023-01-01 to 2025-12-31."""
raw = pd.read_excel(SOURCE_FILE, sheet_name="Orders")
returns = pd.read_excel(SOURCE_FILE, sheet_name="Returns")
# Orders with more than one Customer ID (a known data issue), found before filtering.
ids_per_order = raw.groupby("Order ID")["Customer ID"].nunique()
conflict_orders = set(ids_per_order[ids_per_order > 1].index)
in_scope = (raw["Order Date"] >= "2023-01-01") & (raw["Order Date"] < "2026-01-01")
df = raw.loc[in_scope].copy()
df = df.rename(columns={
"Row ID": "row_id", "Order ID": "order_id", "Order Date": "order_date",
"Ship Date": "ship_date", "Ship Mode": "ship_mode", "Customer ID": "customer_id",
"Segment": "segment", "Country/Region": "country_region", "Region": "region",
"Category": "category", "Sub-Category": "sub_category", "Product ID": "product_id",
"Product Name": "product_name", "Sales": "sales", "Quantity": "quantity",
"Discount": "discount", "Profit": "profit",
})
df["returned_order_flag"] = df["order_id"].isin(set(returns["Order ID"])).astype(int)
df["customer_order_conflict"] = df["order_id"].isin(conflict_orders).astype(int)
df["customer_analysis_eligible"] = 1 - df["customer_order_conflict"]
df["order_to_ship_days"] = (df["ship_date"] - df["order_date"]).dt.days
df["order_month"] = df["order_date"].dt.to_period("M").dt.to_timestamp()
columns = [
"row_id", "order_id", "order_date", "ship_date", "ship_mode", "customer_id",
"segment", "country_region", "region", "category", "sub_category", "product_id",
"product_name", "sales", "quantity", "discount", "profit", "returned_order_flag",
"customer_analysis_eligible", "customer_order_conflict", "order_to_ship_days",
"order_month",
]
return df[columns].sort_values("row_id").reset_index(drop=True)
pd.set_option("display.float_format", "{:,.2f}".format)
plt.rcParams.update({
"figure.figsize": (8, 4),
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"axes.axisbelow": True,
"grid.color": "#e6e5e1",
"axes.prop_cycle": plt.cycler(color=["#2a78d6", "#eb6834", "#1baf7a"]),
"axes.titleweight": "bold",
"axes.titlelocation": "left",
})
print("Setup complete.")
Setup complete.
import hashlib
import io
def fetch(url, expected_sha256):
"""Download a file into memory and check its SHA-256 fingerprint."""
with urllib.request.urlopen(url) as response:
data = response.read()
if hashlib.sha256(data).hexdigest() != expected_sha256:
print("WARNING: this file has changed since the course was written:", url)
print("Your numbers may differ from the lab. Ask your facilitator.")
return data
world_cup_bytes = fetch(
"https://public.tableau.com/app/sample-data/world_cup_results.xlsx",
"dbad641c16c914c14fef0c95bf6f918fa5d2746671e4dc269b6a1ec3e2a3cc45",
)
mobile_bytes = fetch(
"https://public.tableau.com/app/sample-data/mobile_os_usage.csv",
"0db44110e84a31c06491af89a30949cd8ced1c42fac56488cb04ca4f9e449995",
)
matches_raw = pd.read_excel(io.BytesIO(world_cup_bytes), sheet_name="WorldCupMatches")
cups = pd.read_excel(io.BytesIO(world_cup_bytes), sheet_name="WorldCups")
mobile_raw = pd.read_csv(io.BytesIO(mobile_bytes))
print("World Cup matches:", matches_raw.shape)
print("World Cup tournaments:", cups.shape)
print("Mobile OS usage:", mobile_raw.shape)
World Cup matches: (852, 12)
World Cup tournaments: (20, 10)
Mobile OS usage: (1898, 3)
Start the cleaning log now. Every step you take today adds one row to it.
cleaning_log = []
def log_step(dataset, change, why, before, after):
"""Add one row to the cleaning log."""
cleaning_log.append({"dataset": dataset, "change": change, "why": why,
"rows_before": before, "rows_after": after})
print("Cleaning log ready.")
Cleaning log ready.
G2 (Core): Profile the match results¶
Before fixing anything, look. What does one row represent, what types are the columns, and is anything missing?
matches_raw.head()
| Year | Date | Time | Round | Stadium | City | Country | HomeTeam | HomeGoals | AwayGoals | AwayTeam | Observation | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1930 | 1930-07-13 | 15:00 | Group 1 | Pocitos | Montevideo | Uruguay | France | 4 | 1 | Mexico | |
| 1 | 1930 | 1930-07-13 | 15:00 | Group 4 | Parque Central | Montevideo | Uruguay | USA | 3 | 0 | Belgium | |
| 2 | 1930 | 1930-07-14 | 12:45 | Group 2 | Parque Central | Montevideo | Uruguay | Yugoslavia | 2 | 1 | Brazil | |
| 3 | 1930 | 1930-07-14 | 14:50 | Group 3 | Pocitos | Montevideo | Uruguay | Romania | 3 | 1 | Peru | |
| 4 | 1930 | 1930-07-15 | 16:00 | Group 1 | Parque Central | Montevideo | Uruguay | Argentina | 1 | 0 | France |
print(matches_raw.dtypes)
print()
print("Missing values per column:")
print(matches_raw.isna().sum())
Year int64
Date datetime64[ns]
Time object
Round object
Stadium object
City object
Country object
HomeTeam object
HomeGoals int64
AwayGoals int64
AwayTeam object
Observation object
dtype: object
Missing values per column:
Year 0
Date 0
Time 0
Round 0
Stadium 0
City 0
Country 0
HomeTeam 0
HomeGoals 0
AwayGoals 0
AwayTeam 0
Observation 0
dtype: int64
One row is one match: a year, a date, two teams and their goals. The Observation column holds notes such as "win after extra time".
isna() reports no missing values at all, yet most matches have no note. Blanks can be stored as empty text instead of NaN, and then isna() cannot see them:
empty_notes = matches_raw["Observation"].astype(str).str.strip() == ""
print("Observation cells that are empty text:", empty_notes.sum(), "of", len(matches_raw))
Observation cells that are empty text: 787 of 852
Here an empty note is expected (most matches end normally), so we leave it. The lesson: check for empty text as well as NaN before you call a column complete.
The WorldCups sheet records the official number of matches and goals for each tournament. That gives us an independent check: count the match rows per year and compare.
check = pd.DataFrame({
"match_rows": matches_raw.groupby("Year").size(),
"official_matches": cups.set_index("Year")["MatchesPlayed"],
})
check["difference"] = check["match_rows"] - check["official_matches"]
check[check["difference"] != 0]
| match_rows | official_matches | difference | |
|---|---|---|---|
| Year | |||
| 2014 | 80 | 64 | 16 |
Predict before you continue
Only one tournament disagrees, and it has more rows than matches played. Write down two possible explanations before you run G3.
G3 (Core): Find and remove the duplicates¶
duplicated() marks rows that are identical, in every column, to an earlier row.
is_copy = matches_raw.duplicated()
print("Exact duplicate rows:", is_copy.sum())
print("Years affected:", matches_raw.loc[is_copy, "Year"].value_counts().to_dict())
Exact duplicate rows: 16
Years affected: {2014: 16}
Look at both copies of a few of them. keep=False marks the first copy too, so you can see the pairs side by side.
pairs = matches_raw[matches_raw.duplicated(keep=False)]
pairs.sort_values(["Date", "HomeTeam"])[["Year", "Date", "Round", "HomeTeam", "HomeGoals", "AwayGoals", "AwayTeam"]].head(6)
| Year | Date | Round | HomeTeam | HomeGoals | AwayGoals | AwayTeam | |
|---|---|---|---|---|---|---|---|
| 820 | 2014 | 2014-06-28 | Round of 16 | Brazil | 1 | 1 | Chile |
| 836 | 2014 | 2014-06-28 | Round of 16 | Brazil | 1 | 1 | Chile |
| 821 | 2014 | 2014-06-28 | Round of 16 | Colombia | 2 | 0 | Uruguay |
| 837 | 2014 | 2014-06-28 | Round of 16 | Colombia | 2 | 0 | Uruguay |
| 833 | 2014 | 2014-06-29 | Round of 16 | Costa Rica | 1 | 1 | Greece |
| 839 | 2014 | 2014-06-29 | Round of 16 | Costa Rica | 1 | 1 | Greece |
Same teams, same date, same score, same stadium: the same match recorded twice. Each match is one real event, so these are true duplicates. Remove them from a copy, log the step, and run the independent check again.
before = len(matches_raw)
matches = matches_raw.drop_duplicates().copy()
log_step("World Cup matches", "Removed exact duplicate rows",
"Same match recorded twice; match count exceeded the official total", before, len(matches))
check["match_rows_clean"] = matches.groupby("Year").size()
print("Years where clean rows differ from the official count:",
(check["match_rows_clean"] != check["official_matches"]).sum())
Years where clean rows differ from the official count: 0
Remember
The fix is confirmed by a number that came from somewhere else (the official match count), not just by "the duplicates are gone". Copy the before and after counts into your evidence log.
G4 (Core): Text problems — spaces, broken characters, names¶
Text columns hide problems that are invisible when you look at a few rows. Start with stray spaces.
for column in ["City", "Stadium", "HomeTeam", "AwayTeam"]:
text = matches[column].astype(str)
print(f"{column:9} values with leading or trailing spaces: {(text != text.str.strip()).sum()}")
City values with leading or trailing spaces: 836
Stadium values with leading or trailing spaces: 0
HomeTeam values with leading or trailing spaces: 0
AwayTeam values with leading or trailing spaces: 0
Every City value ends with a space. That is harmless until you join on city or type "Paris" in a filter. Strip it and log it (a text fix does not change the row count, so before and after are the same).
matches["City"] = matches["City"].str.strip()
log_step("World Cup matches", "Stripped spaces from City", "Every City value ended in a space",
len(matches), len(matches))
print("Cities with spaces left:", (matches["City"] != matches["City"].str.strip()).sum())
Cities with spaces left: 0
Next, broken characters. The symbols � are what is left of letters such as ô or é after the file passed through the wrong encoding. The original letter is gone.
text_columns = ["City", "Stadium", "HomeTeam", "AwayTeam"]
broken = matches[text_columns].apply(lambda column: column.astype(str).str.contains("�"))
print("Rows with a broken character:", broken.any(axis=1).sum())
pd.Series(pd.unique(matches[text_columns].where(broken).stack())).head(12)
Rows with a broken character: 73
0 Stade V�lodrome
1 Maracan� - Est�dio Jornalista M�rio Filho
2 Malm�
3 Norrk�Ping
4 Nou Camp - Estadio Le�n
5 D�Sseldorf
6 Estadio Jos� Mar�a Minella
7 Estadio Ol�mpico Chateau Carreras
8 Estadio Municipal de Bala�dos
9 La Coru�A
10 Estadio Ol�mpico Universitario
11 C�te d'Ivoire
dtype: object
Some of these are easy to read (C�te d'Ivoire is Côte d'Ivoire), others are not. We flag the affected rows rather than guessing; correcting them needs a trusted reference list.
matches["text_broken_flag"] = broken.any(axis=1).astype(int)
log_step("World Cup matches", "Flagged rows with broken characters (not corrected)",
"Encoding damage; original letters cannot be recovered from the file", len(matches), len(matches))
matches["text_broken_flag"].value_counts()
text_broken_flag
0 763
1 73
Name: count, dtype: int64
Finally, names. Look for teams that might be the same country under two names.
teams = pd.concat([matches["HomeTeam"], matches["AwayTeam"]])
teams[teams.str.contains("Iran|Germany")].value_counts()
Germany FR 62
Germany 44
Iran 6
IR Iran 6
Name: count, dtype: int64
Not every difference is an error
IranandIR Iranare the same team written two ways: a candidate for a documented mapping.Germany FR(West Germany),German DR(East Germany) andGermanyare historically different teams. Merging them is an analytical choice, not a fix, and must be stated if you make it.You decide how to treat each one in I4.
G5 (Core): Join the three Superstore sheets safely¶
The Superstore workbook has three sheets. Read them raw from the file the setup cell downloaded.
orders_raw = pd.read_excel(SOURCE_FILE, sheet_name="Orders")
returns_raw = pd.read_excel(SOURCE_FILE, sheet_name="Returns")
people_raw = pd.read_excel(SOURCE_FILE, sheet_name="People")
print("Orders: ", orders_raw.shape)
print("Returns:", returns_raw.shape)
print("People: ", people_raw.shape)
people_raw
Orders: (10194, 21)
Returns: (296, 2)
People: (4, 2)
| Regional Manager | Region | |
|---|---|---|
| 0 | Sadie Pawthorne | West |
| 1 | Chuck Magee | East |
| 2 | Roxanne Rodriguez | Central |
| 3 | Fred Suzuki | South |
Step 1: before the join, check the keys. Orders will be joined to Returns on Order ID and to People on Region. Each key must be unique on the Returns and People side, or rows will multiply.
print("Returns: is Order ID unique?", returns_raw["Order ID"].is_unique)
print("People: is Region unique? ", people_raw["Region"].is_unique)
print("Returns: values in Returned:", returns_raw["Returned"].unique())
Returns: is Order ID unique? True
People: is Region unique? True
Returns: values in Returned: ['Yes']
Step 2: join with validate and indicator. Each order line has at most one returns row and exactly one region manager, so both joins are many-to-one.
step1 = orders_raw.merge(returns_raw, on="Order ID", how="left",
validate="many_to_one", indicator="returns_match")
step2 = step1.merge(people_raw, on="Region", how="left",
validate="many_to_one", indicator="people_match")
print("Rows: orders", len(orders_raw), "| after Returns", len(step1), "| after People", len(step2))
print()
print(step2["returns_match"].value_counts())
print()
print(step2["people_match"].value_counts())
Rows: orders 10194 | after Returns 10194 | after People 10194
returns_match
left_only 9394
both 800
right_only 0
Name: count, dtype: int64
people_match
both 10194
left_only 0
right_only 0
Name: count, dtype: int64
Step 3: after the join, check a total and the unmatched rows.
print("Sales before joins:", round(orders_raw["Sales"].sum(), 2))
print("Sales after joins: ", round(step2["Sales"].sum(), 2))
print("Lines without a manager:", step2["Regional Manager"].isna().sum())
print("Listed orders found in Orders:", returns_raw["Order ID"].isin(orders_raw["Order ID"]).sum(),
"of", len(returns_raw))
Sales before joins: 2326534.35
Sales after joins: 2326534.35
Lines without a manager: 0
Listed orders found in Orders: 296 of 296
Read the output above:
- The row count and the Sales total did not change, so neither join multiplied anything.
- Lines whose order is not on the returns list get a blank
Returned. Blank here means "not listed", not "unknown": that is whyload_orders()turns it into a 0/1 flag. - Every line found a manager.
Turn the blank into an explicit flag, and log the join.
orders_joined = step2.drop(columns=["returns_match", "people_match"])
orders_joined["returned_listed"] = orders_joined["Returned"].eq("Yes").astype(int)
log_step("Superstore", "Left-joined Returns (on Order ID) and People (on Region)",
"Add returns listing and regional manager to each line; validated many-to-one",
len(orders_raw), len(orders_joined))
orders_joined[["Order ID", "Region", "Regional Manager", "Returned", "returned_listed"]].head()
| Order ID | Region | Regional Manager | Returned | returned_listed | |
|---|---|---|---|---|---|
| 0 | US-2023-103800 | Central | Roxanne Rodriguez | NaN | 0 |
| 1 | US-2023-112326 | Central | Roxanne Rodriguez | NaN | 0 |
| 2 | US-2023-112326 | Central | Roxanne Rodriguez | NaN | 0 |
| 3 | US-2023-112326 | Central | Roxanne Rodriguez | NaN | 0 |
| 4 | US-2023-141817 | East | Chuck Magee | NaN | 0 |
Independent tasks¶
I1 (Core): Profile the Mobile OS file¶
The file has three columns: a month, an operating system and its percentage of page views. Profile it the same way you profiled the matches.
print(mobile_raw.dtypes)
print()
print("Missing values per column:")
print(mobile_raw.isna().sum())
mobile_raw.head()
Date object
Mobile Operating System object
Percent of Usage float64
dtype: object
Missing values per column:
Date 1772
Mobile Operating System 0
Percent of Usage 0
dtype: int64
| Date | Mobile Operating System | Percent of Usage | |
|---|---|---|---|
| 0 | 2008-12-01 | Android | 0.00 |
| 1 | 2008-12-01 | iOS | 32.56 |
| 2 | 2008-12-01 | SymbianOS | 42.02 |
| 3 | 2008-12-01 | Series 40 | 0.00 |
| 4 | 2008-12-01 | BlackBerry OS | 0.00 |
Most dates are missing. Which months do have dates, how many operating systems does each have, and do their percentages add up to about 100?
dated = mobile_raw.dropna(subset=["Date"])
dated.groupby("Date").agg(
systems=("Mobile Operating System", "nunique"),
percent_total=("Percent of Usage", "sum"),
)
| systems | percent_total | |
|---|---|---|
| Date | ||
| 2008-12-01 | 26 | 100.00 |
| 2009-01-01 | 26 | 100.00 |
| 2009-02-01 | 26 | 100.01 |
| 2009-03-01 | 26 | 99.99 |
| 2009-04-01 | 22 | 99.17 |
Record in your evidence log:
- How many rows have a date, and how many do not.
- Which months are dated, and which of them look complete (all systems present, total close to 100).
- One month that looks incomplete, and how you can tell.
I2 (Core): Choose a subset you can defend¶
The undated rows cannot be placed in time. It is tempting to give them dates based on their position in the file, but row order is not evidence: a file can be sorted, filtered or appended in any order.
The defensible choice is to keep only the dated rows, and to mark the month that is incomplete.
before = len(mobile_raw)
mobile = mobile_raw.dropna(subset=["Date"]).copy()
mobile["Date"] = pd.to_datetime(mobile["Date"])
month_totals = mobile.groupby("Date")["Percent of Usage"].sum()
complete_months = month_totals[(month_totals - 100).abs() < 0.5].index
mobile["complete_month"] = mobile["Date"].isin(complete_months).astype(int)
log_step("Mobile OS", "Kept only rows with a date; flagged incomplete months",
"Undated rows cannot be placed in time; row order is not evidence", before, len(mobile))
mobile.groupby("Date")["complete_month"].first()
Date
2008-12-01 1
2009-01-01 1
2009-02-01 1
2009-03-01 1
2009-04-01 0
Name: complete_month, dtype: int64
Your turn: change the code in the next cell and run it again.
# 1. Run the cell: it charts the share of page views for two systems, complete months only.
# 2. Change the list to two or three other systems, for example "BlackBerry OS" or "Android".
# 3. Rewrite the title so it says exactly what the chart shows, including the period.
selected = ["iOS", "SymbianOS"]
subset = mobile[(mobile["complete_month"] == 1) & mobile["Mobile Operating System"].isin(selected)]
chart = subset.pivot(index="Date", columns="Mobile Operating System", values="Percent of Usage")
ax = chart.plot(marker="o", title="Share of mobile page views, Dec 2008 – Mar 2009 (Statcounter)")
ax.set_xlabel("")
ax.set_ylabel("Percent of page views")
plt.tight_layout()
plt.show()

Write one sentence describing your chart and one sentence on what the file cannot tell you (think about what a page-view share measures, and about the period covered).
I3 (Core): How much did the duplicates matter?¶
Compare goals per match for each tournament using the raw and the clean match tables.
Your turn: change the code in the next cell and run it again.
# 1. Run the cell and find the tournament where raw and clean differ.
# 2. Change "goals_per_match" to "matches" to compare match counts instead.
# 3. Then compare the clean goals with the official GoalsScored column in cups.
def per_tournament(table):
goals = table["HomeGoals"] + table["AwayGoals"]
return pd.DataFrame({
"matches": table.groupby("Year").size(),
"goals": goals.groupby(table["Year"]).sum(),
}).assign(goals_per_match=lambda t: t["goals"] / t["matches"])
comparison = pd.DataFrame({
"raw": per_tournament(matches_raw)["goals_per_match"],
"clean": per_tournament(matches)["goals_per_match"],
})
comparison.tail(5)
| raw | clean | |
|---|---|---|
| Year | ||
| 1998 | 2.67 | 2.67 |
| 2002 | 2.52 | 2.52 |
| 2006 | 2.30 | 2.30 |
| 2010 | 2.27 | 2.27 |
| 2014 | 2.58 | 2.67 |
Did the duplicates change the average much? Would they have changed a total (all goals scored in 2014) more or less? Write your answer, with the numbers, in your log.
I4 (Core): Finish the cleaning log¶
Decide how to treat Iran / IR Iran and the German teams from G4. Add one row to the log for your decision, even if the decision is "leave unchanged".
Your turn: change the code in the next cell and run it again.
# 1. Edit the mapping: add or remove names you decide to combine.
# 2. Fill in the reason in log_step. If you change nothing, log that decision too.
name_map = {"IR Iran": "Iran"}
before_names = pd.concat([matches["HomeTeam"], matches["AwayTeam"]]).nunique()
matches["HomeTeam"] = matches["HomeTeam"].replace(name_map)
matches["AwayTeam"] = matches["AwayTeam"].replace(name_map)
after_names = pd.concat([matches["HomeTeam"], matches["AwayTeam"]]).nunique()
log_step("World Cup matches", f"Renamed teams: {name_map}", "Write your reason here",
len(matches), len(matches))
print("Different team names before:", before_names, "| after:", after_names)
Different team names before: 83 | after: 82
Now print the whole log. This table is your main evidence for today.
pd.DataFrame(cleaning_log)
| dataset | change | why | rows_before | rows_after | |
|---|---|---|---|---|---|
| 0 | World Cup matches | Removed exact duplicate rows | Same match recorded twice; match count exceede... | 852 | 836 |
| 1 | World Cup matches | Stripped spaces from City | Every City value ended in a space | 836 | 836 |
| 2 | World Cup matches | Flagged rows with broken characters (not corre... | Encoding damage; original letters cannot be re... | 836 | 836 |
| 3 | Superstore | Left-joined Returns (on Order ID) and People (... | Add returns listing and regional manager to ea... | 10194 | 10194 |
| 4 | Mobile OS | Kept only rows with a date; flagged incomplete... | Undated rows cannot be placed in time; row ord... | 1898 | 126 |
| 5 | World Cup matches | Renamed teams: {'IR Iran': 'Iran'} | Write your reason here | 836 | 836 |
I5 (Stretch): Reshape the Mobile OS data¶
Make a wide table with one row per operating system and one column per month, then check each month's total and melt it back to long.
Your turn: change the code in the next cell and run it again.
# 1. Run the cell. Which month's column does not add up to about 100?
# 2. Change sort_values("2008-12-01") to sort by another month.
wide = mobile.pivot(index="Mobile Operating System", columns="Date", values="Percent of Usage")
wide.columns = wide.columns.strftime("%Y-%m-%d")
print("Column totals:")
print(wide.sum().round(2))
wide.sort_values("2008-12-01", ascending=False).head(8)
Column totals:
Date
2008-12-01 100.00
2009-01-01 100.00
2009-02-01 100.01
2009-03-01 99.99
2009-04-01 99.17
dtype: float64
| Date | 2008-12-01 | 2009-01-01 | 2009-02-01 | 2009-03-01 | 2009-04-01 |
|---|---|---|---|---|---|
| Mobile Operating System | |||||
| SymbianOS | 42.02 | 39.31 | 37.04 | 35.12 | 34.25 |
| iOS | 32.56 | 35.56 | 36.43 | 39.60 | 41.91 |
| Unknown | 16.00 | 14.73 | 11.87 | 11.29 | 10.20 |
| Playstation | 4.47 | 4.33 | 3.58 | 3.18 | 2.85 |
| WinCE | 2.22 | 2.10 | 1.94 | 1.70 | 1.55 |
| Linux | 1.66 | 1.24 | 0.00 | 0.00 | 0.00 |
| Other | 1.07 | 1.12 | 1.52 | 0.95 | NaN |
| Android | 0.00 | 0.66 | 2.06 | 2.18 | 2.02 |
long_again = wide.reset_index().melt(id_vars="Mobile Operating System", var_name="Date",
value_name="Percent of Usage").dropna()
print("Rows in mobile:", len(mobile), "| rows after reshaping back:", len(long_again))
Rows in mobile: 126 | rows after reshaping back: 126
Explain why some cells in the wide table are blank, and why we drop them (instead of filling them with 0) when melting back.
I6 (Stretch): Which listed returns fall in the course period?¶
The Returns sheet lists orders from every year in the Orders sheet. Our course period is orders placed 2023 to 2025. How many listed orders fall inside it?
Your turn: change the code in the next cell and run it again.
# 1. Run the cell.
# 2. Change the period to only 2025 ("2025-01-01" to "2026-01-01") and run again.
start, end = "2023-01-01", "2026-01-01"
order_dates = orders_raw.groupby("Order ID")["Order Date"].min()
listed_dates = order_dates.reindex(returns_raw["Order ID"])
in_period = (listed_dates >= start) & (listed_dates < end)
print("Orders on the returns list:", len(returns_raw))
print("Of those, placed in the period:", in_period.sum())
Orders on the returns list: 296
Of those, placed in the period: 191
Compare your result with the number of distinct listed orders in load_orders() (Session 2, task G6). Do they agree?
Challenge¶
C1 (Challenge): The sales total that grew¶
A colleague adds regional sales targets to the order lines and reports total Sales from the joined table. Their total is bigger than yours.
Your turn: change the code in the next cell and run it again.
# The colleague's code. It runs without errors, but the total is wrong.
# 1. Find out why the joined table has more rows than orders_raw.
# 2. Fix the targets table or the join so every order line appears exactly once.
# 3. Add validate= so the same mistake can never pass silently again.
targets = pd.DataFrame({
"Region": ["West", "West", "East", "Central", "South"],
"version": ["original", "revised", "original", "original", "original"],
"target": [700_000, 760_000, 650_000, 500_000, 400_000],
})
with_targets = orders_raw.merge(targets, on="Region", how="left")
print("Rows:", len(orders_raw), "->", len(with_targets))
print("Total Sales reported by the colleague:", round(with_targets["Sales"].sum(), 2))
print("Total Sales in the Orders sheet: ", round(orders_raw["Sales"].sum(), 2))
Rows: 10194 -> 13447
Total Sales reported by the colleague: 3066347.96
Total Sales in the Orders sheet: 2326534.35
How would you check your fix?
Your fixed join must keep the row count and the Sales total of
orders_raw. Write a two-sentence message to the colleague that explains the mistake without blaming anyone, and names the check that would have caught it.
C2 (Challenge): The tempting fill¶
Look at the rows straight after the last dated April 2009 row.
mobile_raw.iloc[118:132]
| Date | Mobile Operating System | Percent of Usage | |
|---|---|---|---|
| 118 | 2009-04-01 | WinCE | 1.55 |
| 119 | 2009-04-01 | Nintendo 3DS | 0.00 |
| 120 | 2009-04-01 | Nintendo | 0.00 |
| 121 | 2009-04-01 | webOS | 0.00 |
| 122 | 2009-04-01 | WinXP | 0.00 |
| 123 | 2009-04-01 | Brew | 0.00 |
| 124 | 2009-04-01 | Motorola | 0.00 |
| 125 | 2009-04-01 | Win7 | 0.00 |
| 126 | NaN | MeeGo | 0.00 |
| 127 | NaN | Maemo 5 | 0.00 |
| 128 | NaN | WinVista | 0.00 |
| 129 | NaN | Other | 0.85 |
| 130 | NaN | Android | 1.86 |
| 131 | NaN | iOS | 37.32 |
April has fewer systems than the other months, and its total is below 100. The four undated rows that follow look like exactly the missing systems, and adding them brings April close to 100. A colleague proposes filling every blank date with the date of the row above it.
In your log, answer:
- What evidence supports giving those four rows the April date? What evidence is missing?
- What would forward-filling do to the other undated rows, and why is that far more dangerous?
- What would you need from the data provider to use the undated rows at all?
Exit ticket¶
- One duplicate you removed and the independent number that confirmed the fix.
- Your Mobile OS decision in one sentence, with its reason.
- The three checks you ran around the Superstore join.
- Why the colleague's total in C1 grew.
- One thing you are still unsure about.
Evidence to keep¶
- Duplicate check against the official match counts, before and after (G2–G3)
- Text problems found and how each was treated: fixed, flagged or left (G4, I4)
- Superstore join with key checks,
validate,indicator, row counts and Sales total (G5) - Mobile OS profile and your defended subset (I1–I2)
- Your complete cleaning log (I4)
- Your fix and message for the debugging challenge (C1), if attempted
After the lab¶
- Previous: Session 3 lecture. Next: Session 4 lecture, descriptive statistics and distributions.