Implement tested preprocessing utilities and visualize feature distributions
Implement Tested Preprocessing Utilities and Visualize Feature Distributions
Build reusable Python preprocessing utilities, verify them with pytest, and create diagnostic plots that reveal missingness, skew, outliers and train-versus-held-out distribution differences.
1. What You Will Build
This implementation separates exploratory work from reusable code. The preprocessing utilities will live in a Python module, automated tests will verify their behavior, and a runnable script will load a small CSV, create a held-out dataset, fit preprocessing on training data only, and save diagnostic plots.
| File | Purpose |
|---|---|
preprocessing_utils.py | Reusable cleaning, validation, preprocessing and plotting utilities |
test_preprocessing_utils.py | Automated pytest tests |
run_preprocessing.py | End-to-end runnable workflow |
customer_features.csv | Small practice dataset |
artifacts/ | Saved plots, reports and fitted preprocessor |
2. Prerequisites
Required Setup
- Python 3.9 or later is recommended.
- Basic knowledge of pandas DataFrames and NumPy arrays.
- Basic understanding of train and held-out datasets.
- Use pytest for automated tests.
- Use Matplotlib for feature-distribution plots.
python -m pip install numpy pandas matplotlib scikit-learn pytest joblib
3. Recommended Project Structure
scientific_preprocessing/
├── customer_features.csv
├── preprocessing_utils.py
├── run_preprocessing.py
├── test_preprocessing_utils.py
└── artifacts/
Keeping reusable functions outside a notebook makes them easier to test, import and reuse. A notebook may call these utilities for exploration, but the core preprocessing logic should not depend on hidden notebook state.
4. Create the Practice Dataset
Run the following code once to create a small CSV containing missing numerical values, inconsistent categories, an invalid numeric token and an invalid date.
from pathlib import Path
import pandas as pd
data = pd.DataFrame({
"customer_id": list(range(1001, 1025)),
"age": [
22, 25, 29, None, 35, 41, 47, 52,
31, 28, 38, 44, 50, 26, None, 33,
36, 42, 55, 30, 27, 49, 40, 34
],
"monthly_income": [
28000, 32000, 39000, 46000, None, 62000, 71000, 83000,
48000, 37000, 56000, "unknown", 76000, 35000, 43000, 51000,
59000, 65000, 90000, 45000, 34000, 74000, 61000, 53000
],
"city": [
"Kolkata", "Delhi", "Mumbai", " kolkata ", "Pune", "Delhi",
"Mumbai", "Kolkata", None, "Pune", "Delhi", "Mumbai",
"Kolkata", "Pune", "Delhi", "Mumbai", "Kolkata", "Delhi",
"Pune", "Mumbai", "Kolkata", "Delhi", "Pune", "Bengaluru"
],
"segment": [
"Standard", "Standard", "Premium", "Standard", None, "Premium",
"Premium", "Premium", "Standard", "Standard", "Premium", "Premium",
"Premium", "Standard", "Standard", "Premium", "Standard", "Premium",
"Premium", "Standard", "Standard", "Premium", "Premium", "Standard"
],
"signup_date": [
"2025-01-05", "2025-01-18", "2025-02-03", "2025-02-20",
"2025-03-09", "2025-03-22", "invalid", "2025-04-15",
"2025-05-01", "2025-05-19", "2025-06-02", "2025-06-18",
"2025-07-04", None, "2025-08-01", "2025-08-17",
"2025-09-05", "2025-09-21", "2025-10-03", "2025-10-19",
"2025-11-02", "2025-11-16", "2025-12-01", "2025-12-18"
],
"purchased": [
0, 0, 1, 0, 0, 1, 1, 1,
0, 0, 1, 1, 1, 0, 0, 1,
0, 1, 1, 0, 0, 1, 1, 0
]
})
path = Path("customer_features.csv")
data.to_csv(path, index=False)
print("Created:", path.resolve())
print("Shape:", data.shape)
5. Implement the Utility Module
Create preprocessing_utils.py. The module contains deterministic cleaning, schema validation, missingness reporting, feature engineering, train-only preprocessing and plot-generation utilities.
from __future__ import annotations
from pathlib import Path
from typing import Iterable
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
MISSING_TOKENS = {
"": np.nan,
" ": np.nan,
"na": np.nan,
"n/a": np.nan,
"none": np.nan,
"null": np.nan,
"unknown": np.nan,
"?": np.nan,
}
def load_csv(path: str | Path) -> pd.DataFrame:
"""Load a non-empty CSV with unique column names."""
csv_path = Path(path)
if not csv_path.exists():
raise FileNotFoundError(f"CSV not found: {csv_path.resolve()}")
if csv_path.suffix.lower() != ".csv":
raise ValueError("Expected a .csv file")
df = pd.read_csv(csv_path)
if df.empty:
raise ValueError("CSV contains no data rows")
if not df.columns.is_unique:
raise ValueError("CSV contains duplicate column names")
return df
def normalize_column_names(columns: Iterable[str]) -> list[str]:
"""Return normalized snake_case-like column names."""
series = pd.Series(list(columns), dtype="string")
normalized = (
series.str.strip()
.str.lower()
.str.replace(r"[^a-z0-9]+", "_", regex=True)
.str.strip("_")
)
return normalized.tolist()
def clean_structure(df: pd.DataFrame) -> pd.DataFrame:
"""Apply deterministic cleaning without fitting dataset statistics."""
cleaned = df.copy()
cleaned.columns = normalize_column_names(cleaned.columns)
# Normalize known missing tokens without changing legitimate zero values.
cleaned = cleaned.replace(MISSING_TOKENS)
for column in ["age", "monthly_income"]:
if column in cleaned.columns:
cleaned[column] = pd.to_numeric(cleaned[column], errors="coerce")
for column in ["city", "segment"]:
if column in cleaned.columns:
cleaned[column] = (
cleaned[column]
.astype("string")
.str.strip()
.str.title()
)
if "signup_date" in cleaned.columns:
cleaned["signup_date"] = pd.to_datetime(
cleaned["signup_date"],
errors="coerce"
)
return cleaned
def validate_schema(
df: pd.DataFrame,
required_columns: Iterable[str],
target_column: str | None = None
) -> None:
"""Validate columns, duplicate names and optional target completeness."""
required = set(required_columns)
missing = required.difference(df.columns)
if missing:
raise ValueError(f"Missing required columns: {sorted(missing)}")
if not df.columns.is_unique:
raise ValueError("Duplicate column names detected")
if target_column is not None and df[target_column].isna().any():
raise ValueError(f"Target contains missing values: {target_column}")
def validate_finite(df: pd.DataFrame, columns: Iterable[str]) -> None:
"""Raise an error when selected numerical columns contain infinity."""
selected = df[list(columns)].to_numpy(dtype=float)
# NaN may be intentional before imputation, but infinity is never accepted.
if np.isinf(selected).any():
bad_positions = np.argwhere(np.isinf(selected))
raise ValueError(
"Infinite values detected at positions "
f"{bad_positions[:5].tolist()}"
)
def missing_value_report(df: pd.DataFrame) -> pd.DataFrame:
"""Return dtype, missing count and missing percentage per column."""
report = pd.DataFrame({
"dtype": df.dtypes.astype(str),
"missing_count": df.isna().sum(),
"missing_percent": df.isna().mean().mul(100).round(2),
})
return report.sort_values(
["missing_percent", "missing_count"],
ascending=False
)
def build_model_features(df: pd.DataFrame) -> pd.DataFrame:
"""Create deterministic model features from cleaned source fields."""
features = df.copy()
features = features.drop(columns=["customer_id"], errors="ignore")
if "signup_date" in features.columns:
features["signup_year"] = features["signup_date"].dt.year
features["signup_month"] = features["signup_date"].dt.month
features["signup_day_of_week"] = features["signup_date"].dt.dayofweek
features = features.drop(columns=["signup_date"])
return features
def infer_feature_groups(
df: pd.DataFrame
) -> tuple[list[str], list[str]]:
"""Infer numerical and categorical feature names from training data."""
numeric = df.select_dtypes(include=["number"]).columns.tolist()
categorical = df.select_dtypes(
include=["object", "string", "category", "bool"]
).columns.tolist()
unsupported = [
column for column in df.columns
if column not in numeric and column not in categorical
]
if unsupported:
raise TypeError(f"Unsupported feature dtypes: {unsupported}")
if not numeric and not categorical:
raise ValueError("No supported model features found")
return numeric, categorical
def build_preprocessor(
numeric_features: list[str],
categorical_features: list[str]
) -> ColumnTransformer:
"""Create an unfitted preprocessing graph."""
numeric_pipeline = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median", add_indicator=True)),
("scaler", StandardScaler()),
])
categorical_pipeline = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
(
"encoder",
OneHotEncoder(handle_unknown="ignore", sparse_output=False),
),
])
return ColumnTransformer(
transformers=[
("numeric", numeric_pipeline, numeric_features),
("categorical", categorical_pipeline, categorical_features),
],
remainder="drop",
verbose_feature_names_out=False,
)
def transform_to_dataframe(
preprocessor: ColumnTransformer,
df: pd.DataFrame,
*,
fit: bool = False
) -> pd.DataFrame:
"""Fit optionally, transform data and return a labeled DataFrame."""
array = (
preprocessor.fit_transform(df)
if fit
else preprocessor.transform(df)
)
names = preprocessor.get_feature_names_out()
return pd.DataFrame(array, columns=names, index=df.index)
def save_numeric_distributions(
df: pd.DataFrame,
output_path: str | Path,
columns: Iterable[str] | None = None,
bins: int = 15,
) -> Path:
"""Save a grid of numerical histograms."""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
if columns is None:
selected = df.select_dtypes(include=["number"]).columns.tolist()
else:
selected = list(columns)
if not selected:
raise ValueError("No numerical columns supplied for plotting")
n_columns = 2
n_rows = int(np.ceil(len(selected) / n_columns))
fig, axes = plt.subplots(
n_rows,
n_columns,
figsize=(12, 4 * n_rows),
squeeze=False,
)
for axis, column in zip(axes.ravel(), selected):
values = df[column].dropna()
axis.hist(values, bins=bins)
axis.set_title(f"Distribution of {column}")
axis.set_xlabel(column)
axis.set_ylabel("Frequency")
axis.grid(True)
for axis in axes.ravel()[len(selected):]:
axis.set_visible(False)
fig.tight_layout()
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
return output_path
def save_categorical_distributions(
df: pd.DataFrame,
output_path: str | Path,
columns: Iterable[str] | None = None,
) -> Path:
"""Save category-frequency bar charts, including missing values."""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
if columns is None:
selected = df.select_dtypes(
include=["object", "string", "category", "bool"]
).columns.tolist()
else:
selected = list(columns)
if not selected:
raise ValueError("No categorical columns supplied for plotting")
fig, axes = plt.subplots(
len(selected),
1,
figsize=(12, 4 * len(selected)),
squeeze=False,
)
for axis, column in zip(axes.ravel(), selected):
counts = df[column].fillna("Missing").value_counts(dropna=False)
axis.bar(counts.index.astype(str), counts.values)
axis.set_title(f"Category counts for {column}")
axis.set_xlabel(column)
axis.set_ylabel("Count")
axis.tick_params(axis="x", rotation=30)
axis.grid(True, axis="y")
fig.tight_layout()
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
return output_path
def save_train_holdout_histograms(
train_df: pd.DataFrame,
holdout_df: pd.DataFrame,
columns: Iterable[str],
output_path: str | Path,
bins: int = 12,
) -> Path:
"""Save overlaid histograms for train-versus-held-out diagnosis."""
selected = list(columns)
if not selected:
raise ValueError("At least one comparison column is required")
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(
len(selected),
1,
figsize=(12, 4 * len(selected)),
squeeze=False,
)
for axis, column in zip(axes.ravel(), selected):
axis.hist(
train_df[column].dropna(),
bins=bins,
alpha=0.65,
label="Training",
density=True,
)
axis.hist(
holdout_df[column].dropna(),
bins=bins,
alpha=0.65,
label="Held-out",
density=True,
)
axis.set_title(f"Training vs held-out: {column}")
axis.set_xlabel(column)
axis.set_ylabel("Density")
axis.legend()
axis.grid(True)
fig.tight_layout()
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
return output_path
6. Why the Utilities Are Separated
| Utility type | Example | Design reason |
|---|---|---|
| Deterministic transformation | Trim city names or parse dates | Does not learn population statistics |
| Validation | Check schema or infinity | Fails early with a useful diagnostic |
| Learned preprocessing | Median imputation and scaling | Must be fitted on training data only |
| Visualization | Histogram or category count plot | Provides diagnostic evidence without altering data |
7. Missing-Value and Finiteness Policies
8. Implement the Automated Tests
Create test_preprocessing_utils.py. These tests verify normal behavior, shape preservation, error handling, train-only transformations and plot creation.
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from preprocessing_utils import (
build_model_features,
build_preprocessor,
clean_structure,
infer_feature_groups,
missing_value_report,
normalize_column_names,
save_categorical_distributions,
save_numeric_distributions,
transform_to_dataframe,
validate_finite,
validate_schema,
)
@pytest.fixture
def raw_frame():
return pd.DataFrame({
" Customer ID ": [1, 2, 3, 4],
"Age": [20, None, 40, 30],
"Monthly Income": [30000, "unknown", 60000, 45000],
"City": [" kolkata ", "DELHI", None, "Mumbai"],
"Segment": ["Standard", "Premium", "Standard", None],
"Signup Date": ["2025-01-01", "invalid", "2025-02-01", None],
"Purchased": [0, 1, 1, 0],
})
def test_normalize_column_names():
result = normalize_column_names([" Customer ID ", "Monthly Income"])
assert result == ["customer_id", "monthly_income"]
def test_clean_structure_preserves_shape(raw_frame):
cleaned = clean_structure(raw_frame)
assert cleaned.shape == raw_frame.shape
def test_clean_structure_converts_types(raw_frame):
cleaned = clean_structure(raw_frame)
assert pd.api.types.is_numeric_dtype(cleaned["monthly_income"])
assert pd.api.types.is_datetime64_any_dtype(cleaned["signup_date"])
assert pd.isna(cleaned.loc[1, "monthly_income"])
assert pd.isna(cleaned.loc[1, "signup_date"])
assert cleaned.loc[0, "city"] == "Kolkata"
assert cleaned.loc[1, "city"] == "Delhi"
def test_validate_schema_rejects_missing_column(raw_frame):
cleaned = clean_structure(raw_frame).drop(columns=["city"])
with pytest.raises(ValueError, match="Missing required columns"):
validate_schema(cleaned, required_columns=["city", "age"])
def test_validate_schema_rejects_missing_target(raw_frame):
cleaned = clean_structure(raw_frame)
cleaned.loc[0, "purchased"] = np.nan
with pytest.raises(ValueError, match="Target contains missing"):
validate_schema(
cleaned,
required_columns=["purchased"],
target_column="purchased",
)
def test_validate_finite_allows_nan_but_rejects_infinity():
valid_missing = pd.DataFrame({"x": [1.0, np.nan, 3.0]})
validate_finite(valid_missing, ["x"])
invalid = pd.DataFrame({"x": [1.0, np.inf, 3.0]})
with pytest.raises(ValueError, match="Infinite values"):
validate_finite(invalid, ["x"])
def test_missing_report_contains_counts(raw_frame):
cleaned = clean_structure(raw_frame)
report = missing_value_report(cleaned)
assert "missing_count" in report.columns
assert "missing_percent" in report.columns
assert report.loc["age", "missing_count"] == 1
def test_build_model_features_removes_identifier_and_date(raw_frame):
cleaned = clean_structure(raw_frame)
X = cleaned.drop(columns=["purchased"])
features = build_model_features(X)
assert "customer_id" not in features.columns
assert "signup_date" not in features.columns
assert "signup_year" in features.columns
assert "signup_month" in features.columns
assert "signup_day_of_week" in features.columns
def test_preprocessor_produces_matching_train_holdout_columns(raw_frame):
cleaned = clean_structure(raw_frame)
X = build_model_features(cleaned.drop(columns=["purchased"]))
train = X.iloc[:3].copy()
holdout = X.iloc[3:].copy()
numeric, categorical = infer_feature_groups(train)
preprocessor = build_preprocessor(numeric, categorical)
train_result = transform_to_dataframe(preprocessor, train, fit=True)
holdout_result = transform_to_dataframe(preprocessor, holdout, fit=False)
assert list(train_result.columns) == list(holdout_result.columns)
assert train_result.shape[0] == 3
assert holdout_result.shape[0] == 1
assert not train_result.isna().any().any()
assert not holdout_result.isna().any().any()
def test_unknown_holdout_category_does_not_fail(raw_frame):
cleaned = clean_structure(raw_frame)
X = build_model_features(cleaned.drop(columns=["purchased"]))
train = X.iloc[:3].copy()
holdout = X.iloc[[3]].copy()
holdout.loc[holdout.index[0], "city"] = "Bengaluru"
numeric, categorical = infer_feature_groups(train)
preprocessor = build_preprocessor(numeric, categorical)
transform_to_dataframe(preprocessor, train, fit=True)
result = transform_to_dataframe(preprocessor, holdout, fit=False)
assert result.shape[0] == 1
assert np.isfinite(result.to_numpy()).all()
def test_plot_functions_create_nonempty_files(raw_frame, tmp_path):
cleaned = clean_structure(raw_frame)
numeric_path = tmp_path / "numeric.png"
categorical_path = tmp_path / "categorical.png"
save_numeric_distributions(
cleaned,
numeric_path,
columns=["age", "monthly_income"],
)
save_categorical_distributions(
cleaned,
categorical_path,
columns=["city", "segment"],
)
assert numeric_path.exists()
assert numeric_path.stat().st_size > 0
assert categorical_path.exists()
assert categorical_path.stat().st_size > 0
9. Run the Tests
pytest -q
For more detail:
pytest -v
10. Implement the End-to-End Runner
Create run_preprocessing.py. This script performs a stratified split, saves raw distribution plots, fits preprocessing only on training data, validates the outputs and saves the fitted artifact.
from pathlib import Path
import logging
import joblib
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from preprocessing_utils import (
build_model_features,
build_preprocessor,
clean_structure,
infer_feature_groups,
load_csv,
missing_value_report,
save_categorical_distributions,
save_numeric_distributions,
save_train_holdout_histograms,
transform_to_dataframe,
validate_finite,
validate_schema,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
logger = logging.getLogger(__name__)
CSV_PATH = Path("customer_features.csv")
ARTIFACT_DIR = Path("artifacts")
TARGET = "purchased"
SEED = 42
def main() -> None:
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
raw_df = load_csv(CSV_PATH)
clean_df = clean_structure(raw_df)
required_columns = [
"customer_id",
"age",
"monthly_income",
"city",
"segment",
"signup_date",
TARGET,
]
validate_schema(clean_df, required_columns, target_column=TARGET)
validate_finite(clean_df, ["age", "monthly_income"])
logger.info("Raw shape: %s", raw_df.shape)
logger.info("Clean shape: %s", clean_df.shape)
report = missing_value_report(clean_df)
report.to_csv(ARTIFACT_DIR / "missing_value_report.csv")
logger.info("Saved missing-value report")
X = clean_df.drop(columns=[TARGET])
y = clean_df[TARGET].astype("int64")
X_train, X_holdout, y_train, y_holdout = train_test_split(
X,
y,
test_size=0.25,
random_state=SEED,
stratify=y,
)
logger.info("X_train shape: %s", X_train.shape)
logger.info("X_holdout shape: %s", X_holdout.shape)
# Save diagnostic plots before learned preprocessing.
save_numeric_distributions(
X_train,
ARTIFACT_DIR / "training_numeric_distributions.png",
columns=["age", "monthly_income"],
)
save_categorical_distributions(
X_train,
ARTIFACT_DIR / "training_categorical_distributions.png",
columns=["city", "segment"],
)
save_train_holdout_histograms(
X_train,
X_holdout,
columns=["age", "monthly_income"],
output_path=ARTIFACT_DIR / "train_holdout_numeric_comparison.png",
)
X_train_features = build_model_features(X_train)
X_holdout_features = build_model_features(X_holdout)
numeric_features, categorical_features = infer_feature_groups(
X_train_features
)
logger.info("Numeric features: %s", numeric_features)
logger.info("Categorical features: %s", categorical_features)
preprocessor = build_preprocessor(
numeric_features,
categorical_features,
)
# The leakage boundary: fit only on training data.
train_processed = transform_to_dataframe(
preprocessor,
X_train_features,
fit=True,
)
holdout_processed = transform_to_dataframe(
preprocessor,
X_holdout_features,
fit=False,
)
assert train_processed.shape[0] == y_train.shape[0]
assert holdout_processed.shape[0] == y_holdout.shape[0]
assert list(train_processed.columns) == list(holdout_processed.columns)
assert np.isfinite(train_processed.to_numpy()).all()
assert np.isfinite(holdout_processed.to_numpy()).all()
logger.info("Processed train shape: %s", train_processed.shape)
logger.info("Processed holdout shape: %s", holdout_processed.shape)
train_output = train_processed.copy()
train_output[TARGET] = y_train.to_numpy()
train_output.to_csv(
ARTIFACT_DIR / "training_processed.csv",
index=False,
)
holdout_output = holdout_processed.copy()
holdout_output[TARGET] = y_holdout.to_numpy()
holdout_output.to_csv(
ARTIFACT_DIR / "held_out_processed.csv",
index=False,
)
joblib.dump(
preprocessor,
ARTIFACT_DIR / "preprocessor.joblib",
)
shape_report = pd.DataFrame([
{"stage": "raw", "shape": str(raw_df.shape)},
{"stage": "clean", "shape": str(clean_df.shape)},
{"stage": "X_train", "shape": str(X_train.shape)},
{"stage": "X_holdout", "shape": str(X_holdout.shape)},
{"stage": "train_features", "shape": str(X_train_features.shape)},
{"stage": "holdout_features", "shape": str(X_holdout_features.shape)},
{"stage": "train_processed", "shape": str(train_processed.shape)},
{"stage": "holdout_processed", "shape": str(holdout_processed.shape)},
])
shape_report.to_csv(ARTIFACT_DIR / "shape_report.csv", index=False)
logger.info("Workflow completed successfully")
if __name__ == "__main__":
main()
11. Run the Workflow
python run_preprocessing.py
The artifacts directory should contain:
artifacts/
├── held_out_processed.csv
├── missing_value_report.csv
├── preprocessor.joblib
├── shape_report.csv
├── train_holdout_numeric_comparison.png
├── training_categorical_distributions.png
├── training_numeric_distributions.png
└── training_processed.csv
12. Understand the Shape Changes
The raw dataset has 24 rows and 7 columns. Separating the target produces six source feature columns and one target vector:
A 25% held-out split produces 18 training rows and 6 held-out rows:
Feature engineering removes the identifier and replaces one date with three calendar features:
After one-hot encoding and missing indicators, the output has \(k\) columns:
The exact value of \(k\) depends on categories and missing indicators learned from the training subset. Both processed datasets must have the same \(k\) columns in the same order.
13. Interpret Numerical Histograms
Inspect Each Numerical Feature
- Look for impossible values, such as negative age or income.
- Check whether the distribution is strongly skewed.
- Identify isolated extreme values that may require domain review.
- Observe gaps or suspicious spikes caused by data-entry rules.
- Compare training and held-out ranges for possible distribution shift.
14. Interpret Categorical Bar Charts
Inspect Category Counts
- Check whether spelling and capitalization variations were normalized.
- Identify rare categories with very few observations.
- Confirm that missing values are visible as a separate diagnostic count.
- Look for severe category imbalance.
- Confirm whether held-out data contains categories absent from training.
15. Why Visualize Before Scaling?
Standard scaling changes location and scale. Plotting source values before scaling preserves interpretability in real-world units such as years or income. You may additionally visualize transformed features, but transformed plots do not replace source-data inspection.
Before preprocessing
- Inspect values in domain units.
- Find missingness and outliers.
- Review category quality.
After preprocessing
- Verify finite model inputs.
- Confirm training and held-out schemas match.
- Inspect transformed feature ranges.
16. Why Train-Only Fitting Is Tested
The preprocessor learns medians, scaling parameters and category vocabularies. These values must come only from training data.
transform_to_dataframe(preprocessor, X_train_features, fit=True)transform_to_dataframe(preprocessor, X_holdout_features, fit=False)
fit=True for held-out data would recalculate preprocessing rules from evaluation records.
17. Add a Test for a Previously Discovered Bug
Whenever a preprocessing defect is found, create a regression test before or while fixing it. For example, if lowercase "unknown" was converted to missing but uppercase "UNKNOWN" was not, first normalize string tokens or expand the policy, then lock the expected behavior into a test.
def test_unknown_income_token_becomes_missing():
raw = pd.DataFrame({
"monthly_income": ["UNKNOWN", "45000"]
})
# Adapt clean_structure if uppercase token support is required
# by the documented input contract.
cleaned = clean_structure(raw)
assert pd.isna(cleaned.loc[0, "monthly_income"])
assert cleaned.loc[1, "monthly_income"] == 45000
18. Debug Plotting Failures
| Symptom | Diagnostic action |
|---|---|
| Blank histogram | Check whether the selected column is entirely missing after conversion. |
| Categories overlap on the x-axis | Rotate labels, enlarge the figure or display the most frequent categories. |
| One extreme value compresses the plot | Verify the value, then consider a log scale or a separate outlier view. |
| Training and held-out charts look very different | Check split design, small sample size, time effects and population drift. |
| Figure files are not created | Create the output directory and test that saved files exist and are non-empty. |
19. Optional TensorBoard Image Logging
If the workflow is part of a PyTorch experiment, a saved diagnostic plot can be recorded as an experiment artifact. Keep Matplotlib plot creation independent so it remains testable and useful outside the training framework.
from pathlib import Path
from PIL import Image
import numpy as np
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter("runs/preprocessing_debug")
image_path = Path("artifacts/training_numeric_distributions.png")
image = np.asarray(Image.open(image_path).convert("RGB"))
image = np.moveaxis(image, -1, 0) # HWC to CHW
writer.add_image("Data/training_numeric_distributions", image, 0)
writer.close()
20. Production Improvements
Extend the Utilities Carefully
- Define ranges for fields such as age, quantity and monetary values.
- Add schema versioning and explicit expected dtypes.
- Use grouped or chronological splitting when random splitting is inappropriate.
- Persist the fitted preprocessor with the matching trained model.
- Record code revision, dataset version, seed and package versions.
- Monitor production missingness, unknown categories and distribution shift.
- Use continuous integration to run pytest whenever utilities change.
21. Guided Verification Checklist
Confirm the Implementation
- The CSV loads and the raw shape is recorded.
- Column names and known text values are normalized deterministically.
- Invalid numeric and date values become measurable missing values.
- Infinity is rejected explicitly.
- The target is separated before feature preprocessing.
- The held-out dataset is created before fitting learned preprocessing.
- The preprocessor is fitted only on training features.
- The processed training and held-out schemas match.
- Numerical and categorical distribution plots are saved.
- Train-versus-held-out numerical plots are generated.
- pytest verifies transformations, errors, shapes and plot artifacts.
- The fitted preprocessor and evidence reports are saved.
22. Practice Extensions
Extend the Guided Implementation
- Add an interquartile-range report without automatically deleting outliers.
- Add a function that plots missing-value percentages by column.
- Add tests for empty input, duplicate columns and unsupported dtypes.
- Add a chronological split using
signup_date. - Add a test proving that an unseen held-out city does not change the output schema.
- Add model training through a single scikit-learn Pipeline.
- Log distribution images and selected preprocessing statistics to TensorBoard.
Key Takeaway
Reliable preprocessing requires more than a sequence of DataFrame edits. Implement deterministic utilities, validate assumptions, fit learned transformations on training data only, test normal and failure cases, and visualize source distributions before trusting model-ready output. The result should be runnable, observable, reusable and protected by automated evidence.
Get Neural Networks and Deep Learning updates by email
Short, practical lessons and course updates from Rumman Ansari. No spam.