Skip to content

Exercise 2: dirty sales data

Chapter 44 7 code blocks 2379 words

The code in this chapter uses these filesrun it on your own machine

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

python8 lines
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))
Output
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_idorder_idorder_dateregioncategoryproduct_namequantityunit_pricestatus
16 3868 2362025-05-26BuxoroOziq-ovqatMakaron 5kg564000cancelled
5 9342 97026.02.2025ФарғонаUy-ro'zg'orKastryulka 5L1136 000delivered
26 13413 19101.03.2025Toshkent shahriElektronikaKabel Type-C138000delivered
36 54218 4362025-01-03SamarqandKiyimBolalar kiyimi1140000delivered
33 97617 1252025-02-11AndijonUy-ro'zg'orIdish-tovoq 12 dona1280000delivered
12 1476 09708.01.2025Toshkent shahriGo'zallikShampun 400ml249 000delivered
2 9801 4492025-01-20NamanganElektronikaMonitor 24 dyuym11850000delivered
21 28210 76010.06.2025Toshkent shahriUy-ro'zg'orStol lampasi1112 000cancelled
python25 lines
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())
Output
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:

python15 lines
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
1How many rows, how many columns
2"I predict ___ from ___": one sentence
3Task type: regression / binary / multiclass
4The target ratio and the BASELINE number
5Did you split? Do you need stratify?
6The dummy result
7Which columns you dropped, and WHY
8What you did about missing values and types
9Your first honest result
10Which metric you chose, and WHY
11Which group the mistakes cluster in
12Which 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:

python8 lines
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?"},
]))
TCleaning task
1Bring order_date to one format (there are two)
2Turn unit_price into a number (remove the spaces)
3Normalise region: case, spaces, the CYRILLIC alphabet
4Remove the duplicate rows
5Decide what to do with an empty status (it's your target)
6Create 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:

  1. Predict cancelled -> binary, but only 3.5%: severely imbalanced
  2. Predict all three statuses -> multiclass
  3. 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

python26 lines
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())
Output
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.

python26 lines
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")
Output
Rows for the model: 5880
Share of target 'cancelled': 3.61%   <-- severely imbalanced

BASELINE accuracy: 0.9637   <-- accuracy is USELESS here
python16 lines
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)")
Output
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.

Write on Telegram Contents