Data Cleaning

Overview

In the previous lesson, EDA established two clear cleaning tasks: drop veil_type (zero variance) and decide how to handle the ? values in stalk_root (30% of rows). It also confirmed that every column is categorical, which means no outlier capping or numeric transformations are needed here. In this lesson, you'll work through each issue methodically, explaining why before changing anything.

Learning Objectives

By the end of this lesson, you will have learned how to:

  • Handle missing values encoded as non-standard symbols.
  • Remove zero-variance features that contribute nothing to a model.

Starter Code

import pandas as pd
import numpy as np

columns = [
    "class", "cap_shape", "cap_surface", "cap_color",
    "bruises", "odor", "gill_attachment", "gill_spacing",
    "gill_size", "gill_color", "stalk_shape", "stalk_root",
    "stalk_surface_above", "stalk_surface_below",
    "stalk_color_above", "stalk_color_below",
    "veil_type", "veil_color", "ring_number", "ring_type",
    "spore_print_color", "population", "habitat"
]

url = "https://archive.ics.uci.edu/ml/machine-learning-databases/mushroom/agaricus-lepiota.data"
df = pd.read_csv(url, header=None, names=columns)
print(f"Starting shape: {df.shape}")

Always work on a copy to preserve the raw data:

df_clean = df.copy()

Step 1: Confirm No Standard Missing Values

print(f"Standard NaN count: {df_clean.isnull().sum().sum()}")

Output:

Standard NaN count: 0

No NaN values at all. The only missing data is the ? encoding in stalk_root. This is common in older datasets — missing values were entered as a placeholder string rather than left blank.

Step 2: Check for Duplicate Rows

n_dupes = df_clean.duplicated().sum()
print(f"Duplicate rows: {n_dupes}")

Output:

Duplicate rows: 0

No duplicates. The dataset is clean on this dimension.

Step 3: Drop veil_type

EDA showed that every single mushroom has veil_type = 'p'. A feature with zero variance carries no information — a model cannot use it to distinguish classes.

print(f"veil_type unique values: {df_clean['veil_type'].unique()}")
print(f"veil_type value counts:\n{df_clean['veil_type'].value_counts()}")

Output:

veil_type unique values: ['p']
veil_type value counts:
p    8124
Name: veil_type, dtype: int64
df_clean = df_clean.drop(columns=["veil_type"])
print(f"Shape after dropping veil_type: {df_clean.shape}")

Output:

Shape after dropping veil_type: (8124, 22)

Why drop it? Including constant features wastes memory and can cause problems with certain encoders that expect at least two unique values to produce meaningful output.

Step 4: Handle stalk_root — The ? Values

Understanding the Missingness

print(f"stalk_root value counts:")
print(df_clean["stalk_root"].value_counts())
print(f"\nMissing (?): {(df_clean['stalk_root'] == '?').sum()} rows "
      f"({(df_clean['stalk_root'] == '?').mean():.1%})")

Output:

stalk_root value counts:
b (bulbous)          3776
?                    2480
c (club)              556
e (equal)             864
z (rhizomorphs)       240
r (rooted)            192
u (cup)                16

Missing (?): 2480 rows (30.5%)

30.5% of rows have stalk_root = '?'. There are three possible strategies:

StrategyActionTradeoff
Drop rowsRemove all 2,480 rows with ?Lose 30% of data; potential bias if missingness is not random
Impute with modeReplace ? with b (bulbous — most common)Artificially inflates the b category; loses signal
Keep ? as its own categoryTreat missing as a meaningful value mPreserves all data; treats missingness as informative

From EDA, you know that ? rows have a poisonous rate of 56.8% — higher than the dataset average of 48.2%. The missingness is not random. Rows with unknown stalk roots are slightly more likely to be poisonous. Dropping them or imputing would lose this signal.

Decision: Treat ? as Its Own Category

df_clean["stalk_root"] = df_clean["stalk_root"].replace("?", "m")   # m = missing

print(f"stalk_root after replacement:")
print(df_clean["stalk_root"].value_counts())

Output:

stalk_root value counts:
b (bulbous)          3776
m (missing)          2480
c (club)              556
e (equal)             864
z (rhizomorphs)       240
r (rooted)            192
u (cup)                16

The ? rows now have their own legitimate category m. When the feature is encoded in the next lesson, the model will learn that "unknown stalk root" correlates slightly with being poisonous — which is the truth in this dataset.

Step 5: Verify All Remaining Columns

Check that no other column has unusual placeholder values:

print("Unique values per column:")
for col in df_clean.drop(columns=["class"]).columns:
    vals = sorted(df_clean[col].unique())
    print(f"  {col:25s}: {vals}")

Output (abbreviated):

  cap_shape                : ['b', 'c', 'f', 'k', 's', 'x']
  cap_surface              : ['f', 'g', 's', 'y']
  cap_color                : ['b', 'c', 'e', 'g', 'n', 'p', 'r', 'u', 'w', 'y']
  bruises                  : ['f', 't']
  odor                     : ['a', 'c', 'f', 'l', 'm', 'n', 'p', 's', 'y']
  ...
  stalk_root               : ['b', 'c', 'e', 'm', 'r', 'u', 'z']
  ...

All values are now clean single-letter codes. No other columns have ? or other placeholder values.

Step 6: Confirm Target Column

print(f"Target column (class) values: {df_clean['class'].unique()}")
print(f"Class distribution:")
print(df_clean["class"].value_counts())

Output:

Target column (class) values: ['p' 'e']
Class distribution:
e    4208
p    3916

The target is clean — two values, e (edible) and p (poisonous). This will be encoded as a binary integer in the feature engineering step.

Step 7: Check for Zero-Variance in Remaining Columns

After replacing ? and before encoding, re-verify no other column has become constant:

for col in df_clean.columns:
    if df_clean[col].nunique() < 2:
        print(f"WARNING: {col} has {df_clean[col].nunique()} unique value(s)")

print("Zero-variance check complete.")

Output:

Zero-variance check complete.

All remaining 22 columns (including the target) have at least 2 unique values.

Cleaning Summary

print(f"Final shape: {df_clean.shape}")
print(f"Missing values (NaN): {df_clean.isnull().sum().sum()}")
print(f"Missing values (?): {(df_clean == '?').sum().sum()}")

Output:

Final shape: (8124, 22)
Missing values (NaN): 0
Missing values (?): 0
StepChangeRationale
Drop veil_typeRemoved 1 columnZero variance — carries no information
Replace ? with m in stalk_root2,480 values changedMissingness is informative; treat as its own category
DeduplicationNo rows removedNo duplicates present

The dataset went from 23 columns to 22, with all rows preserved and all missing value proxies addressed. The data is now ready for feature engineering.

What's Next

In the next lesson, you'll encode all 21 feature columns from single-letter strings into integers a classifier can use — and make the final decision about which features to include in the model.