Exercise 2: dirty sales data
Why this exercise
The penguins were clean: 7 columns, everything in place, just 11 missing values.
Real data isn't like that. This file has:
- Dates in two different formats
- Region names in two alphabets and mixed letter case
- Prices as text, with spaces inside
- Duplicate rows
- Missing values
And most importantly: there is no target column. You create it yourself.
In this exercise the model itself is only 20 percent of the work. The other 80 percent is cleaning. In practice it's always like that.
What this data is
This is real, uncleaned sales data from the Data Analytics course, from Uzbekistan. The column names are English. The values of the region column are Uzbek province names, written sometimes in the Latin alphabet and sometimes in Cyrillic.
Getting to know the data
import pandas as pd sv = pd.read_csv(DATA / "iflos_savdo.csv") print("shape:", sv.shape) print() print(sv.dtypes.to_string()) print() show(sv.head(8))
shape: (6180, 9) item_id int64 order_id int64 order_date object region object category object product_name object quantity float64 unit_price object status object
| item_id | order_id | order_date | region | category | product_name | quantity | unit_price | status |
|---|---|---|---|---|---|---|---|---|
| 16 386 | 8 236 | 2025-05-26 | Buxoro | Oziq-ovqat | Makaron 5kg | 5 | 64000 | cancelled |
| 5 934 | 2 970 | 26.02.2025 | Фарғона | Uy-ro'zg'or | Kastryulka 5L | 1 | 136 000 | delivered |
| 26 134 | 13 191 | 01.03.2025 | Toshkent shahri | Elektronika | Kabel Type-C | 1 | 38000 | delivered |
| 36 542 | 18 436 | 2025-01-03 | Samarqand | Kiyim | Bolalar kiyimi | 1 | 140000 | delivered |
| 33 976 | 17 125 | 2025-02-11 | Andijon | Uy-ro'zg'or | Idish-tovoq 12 dona | 1 | 280000 | delivered |
| 12 147 | 6 097 | 08.01.2025 | Toshkent shahri | Go'zallik | Shampun 400ml | 2 | 49 000 | delivered |
| 2 980 | 1 449 | 2025-01-20 | Namangan | Elektronika | Monitor 24 dyuym | 1 | 1850000 | delivered |
| 21 282 | 10 760 | 10.06.2025 | Toshkent shahri | Uy-ro'zg'or | Stol lampasi | 1 | 112 000 | cancelled |
print("1) Date formats:") dotted = sv.order_date.astype(str).str.match(r"\d{2}\.\d{2}\.\d{4}").sum() iso = sv.order_date.astype(str).str.match(r"\d{4}-\d{2}-\d{2}").sum() print(f" dotted (26.02.2025): {dotted}") print(f" ISO (2025-05-26): {iso}") print(f" other/empty : {len(sv) - dotted - iso}") print() print("2) unit_price dtype:", sv.unit_price.dtype) print(" written with spaces:", sv.unit_price.astype(str).str.contains(" ").sum()) print(" samples:", sv.unit_price.astype(str).head(4).tolist()) print() print("3) region: number of distinct values:", sv.region.nunique()) print(" but the real number of provinces is much smaller. Here's why:") for v in sorted(sv.region.dropna().unique())[:9]: print(" ", repr(v)) print(" ... in total", sv.region.nunique(), "different spellings") print() print("4) Duplicate rows:", int(sv.duplicated().sum())) print() print("5) Missing values:") missing = sv.isna().sum() print(missing[missing > 0].to_string()) print() print("6) status (the future target):") print(sv.status.value_counts(dropna=False).to_string())
1) Date formats:
dotted (26.02.2025): 1559
ISO (2025-05-26): 3707
other/empty : 914
2) unit_price dtype: object
written with spaces: 1919
samples: ['64000', '136 000', '38000', '140000']
3) region: number of distinct values: 61
but the real number of provinces is much smaller. Here's why:
'ANDIJON'
'Andijon'
'Andijon '
'BUXORO'
'Buxoro'
'Buxoro '
"FARG'ONA"
"Farg'ona"
"Farg'ona "
... in total 61 different spellings
4) Duplicate rows: 180
5) Missing values:
category 185
quantity 124
status 125
6) status (the future target):
status
delivered 5673
cancelled 215
returned 167
NaN 125
The task
Walk the 12 steps from Chapter 1 yourself. Nobody shows you the way. Write the following in your notebook:
import pandas as pd show(pd.DataFrame([ {"#": 1, "Answer to write down": "How many rows, how many columns"}, {"#": 2, "Answer to write down": "\"I predict ___ from ___\": one sentence"}, {"#": 3, "Answer to write down": "Task type: regression / binary / multiclass"}, {"#": 4, "Answer to write down": "The target ratio and the BASELINE number"}, {"#": 5, "Answer to write down": "Did you split? Do you need stratify?"}, {"#": 6, "Answer to write down": "The dummy result"}, {"#": 7, "Answer to write down": "Which columns you dropped, and WHY"}, {"#": 8, "Answer to write down": "What you did about missing values and types"}, {"#": 9, "Answer to write down": "Your first honest result"}, {"#": 10, "Answer to write down": "Which metric you chose, and WHY"}, {"#": 11, "Answer to write down": "Which group the mistakes cluster in"}, {"#": 12, "Answer to write down": "Which ONE change you made, and the result"}, ]))
| # | Answer to write down |
|---|---|
| 1 | How many rows, how many columns |
| 2 | "I predict ___ from ___": one sentence |
| 3 | Task type: regression / binary / multiclass |
| 4 | The target ratio and the BASELINE number |
| 5 | Did you split? Do you need stratify? |
| 6 | The dummy result |
| 7 | Which columns you dropped, and WHY |
| 8 | What you did about missing values and types |
| 9 | Your first honest result |
| 10 | Which metric you chose, and WHY |
| 11 | Which group the mistakes cluster in |
| 12 | Which ONE change you made, and the result |
And most important, question 13:
Which of your hypotheses was REJECTED?
If not a single one was rejected, you didn't test anything. Go back.
Extra tasks
This exercise has a cleaning stage before the 12 steps:
show(pd.DataFrame([ {"T": 1, "Cleaning task": "Bring order_date to one format (there are two)"}, {"T": 2, "Cleaning task": "Turn unit_price into a number (remove the spaces)"}, {"T": 3, "Cleaning task": "Normalise region: case, spaces, the CYRILLIC alphabet"}, {"T": 4, "Cleaning task": "Remove the duplicate rows"}, {"T": 5, "Cleaning task": "Decide what to do with an empty status (it's your target)"}, {"T": 6, "Cleaning task": "Create the target: what will you predict?"}, ]))
| T | Cleaning task |
|---|---|
| 1 | Bring order_date to one format (there are two) |
| 2 | Turn unit_price into a number (remove the spaces) |
| 3 | Normalise region: case, spaces, the CYRILLIC alphabet |
| 4 | Remove the duplicate rows |
| 5 | Decide what to do with an empty status (it's your target) |
| 6 | Create the target: what will you predict? |
The Cyrillic alphabet: the trap people miss most
The region column has both Farg'ona and Фарғона. They're one province, but to a computer they're two different strings.
.str.lower().str.strip() does not fix this: a Cyrillic letter doesn't turn into a Latin one.
If you miss it, OneHotEncoder creates two columns for one province and the model splits the information in half.
With Uzbek data this problem comes up every time.
You create the target yourself: that's a decision
status has delivered, cancelled, returned and empty values. Three ways:
- Predict
cancelled-> binary, but only 3.5%: severely imbalanced - Predict all three statuses -> multiclass
- Predict the revenue (
quantity * unit_price) -> regression
Each has a different metric. Choose and write down why you chose it.
Checking yourself: the cleaning result
import numpy as np CYRILLIC = {"андижон": "andijon", "бухоро": "buxoro", "самарқанд": "samarqand", "тошкент шаҳри": "toshkent shahri", "фарғона": "fargona"} def normalise(x): if pd.isna(x): return np.nan s = str(x).strip().lower() s = CYRILLIC.get(s, s) return s.replace("'", "").replace("`", "").replace("ʻ", "") clean = sv.drop_duplicates().copy() clean["region_clean"] = clean.region.map(normalise) clean["price"] = pd.to_numeric( clean.unit_price.astype(str).str.replace(r"[^\d.]", "", regex=True), errors="coerce") clean["date"] = pd.to_datetime(clean.order_date, format="mixed", dayfirst=True, errors="coerce") print(f"rows : {len(sv)} -> {len(clean)} (duplicates removed)") print(f"region variety : {sv.region.nunique()} -> {clean.region_clean.nunique()}") print(f"price dtype : {sv.unit_price.dtype} -> {clean.price.dtype}") print(f"price NaN : {int(clean.price.isna().sum())}") print(f"date NaN : {int(clean.date.isna().sum())}") print() print("Provinces after normalising:") print(clean.region_clean.value_counts().to_string())
rows : 6180 -> 6000 (duplicates removed) region variety : 61 -> 14 price dtype : object -> int64 price NaN : 0 date NaN : 0 Provinces after normalising: region_clean toshkent shahri 2490 samarqand 559 toshkent viloyati 499 fargona 395 andijon 362 buxoro 287 namangan 277 qashqadaryo 275 navoiy 214 jizzax 176 surxondaryo 155 xorazm 149 qoraqalpogiston 104 sirdaryo 58
region went from 61 different spellings down to 14 real provinces. That's the main work of this exercise.
from sklearn.model_selection import train_test_split, cross_val_score from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression from sklearn.dummy import DummyClassifier from sklearn.metrics import accuracy_score, f1_score, average_precision_score m = clean.dropna(subset=["status", "price", "date"]).copy() m["cancelled"] = (m.status == "cancelled").astype(int) m["month"] = m.date.dt.month m["weekday"] = m.date.dt.dayofweek m["quantity"] = m.quantity.fillna(m.quantity.median()) m["revenue"] = m.quantity * m.price num = ["price", "quantity", "revenue", "month", "weekday"] cat = ["region_clean", "category"] X, y = m[num + cat], m["cancelled"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) print(f"Rows for the model: {len(m)}") print(f"Share of target 'cancelled': {y.mean():.2%} <-- severely imbalanced") print() baseline = accuracy_score(y_te, DummyClassifier(strategy="most_frequent").fit(X_tr[num], y_tr).predict(X_te[num])) print(f"BASELINE accuracy: {baseline:.4f} <-- accuracy is USELESS here")
Rows for the model: 5880 Share of target 'cancelled': 3.61% <-- severely imbalanced BASELINE accuracy: 0.9637 <-- accuracy is USELESS here
pipe = Pipeline([ ("prep", ColumnTransformer([ ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), num), ("cat", Pipeline([("impute", SimpleImputer(strategy="most_frequent")), ("encode", OneHotEncoder(handle_unknown="ignore"))]), cat), ])), ("model", LogisticRegression(max_iter=3000, class_weight="balanced")), ]).fit(X_tr, y_tr) b = pipe.predict(X_te) e = pipe.predict_proba(X_te)[:, 1] print(f"Accuracy : {accuracy_score(y_te, b):.4f} (baseline {baseline:.4f})") print(f"F1 (the cancelled class): {f1_score(y_te, b, zero_division=0):.4f}") print(f"Average Precision : {average_precision_score(y_te, e):.4f}") print(f"AP baseline : {y_te.mean():.4f} (= the share of positives)")
Accuracy : 0.5045 (baseline 0.9637) F1 (the cancelled class): 0.0858 Average Precision : 0.0544 AP baseline : 0.0363 (= the share of positives)
Look at the result and don't be afraid to draw the conclusion: if AP is very close to the baseline, these columns carry almost no signal that predicts cancellation.
The most important lesson of this exercise
Sometimes the right answer is "you can't build a model on this data".
Saying that takes courage. But it's an honest conclusion, and it's worth more to the manager than three weeks of wasted work.
To prove a model can't be built, you need the baseline, AP and CV, which is to say Chapters 7, 9 and 10. You don't say "it didn't work", you show it by measurement.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.