Feature Engineering
Buildable now. The repo, the spec and the deployment steps are live — the written walkthrough for this one is still being drafted.
A reusable sklearn preprocessing pipeline for the same UCI Adult Income data, built to run locally and as a SageMaker Processing job without modification. This is the project that makes the next ones honest — if features are computed differently in training than at serving time, every model after this is quietly wrong in production.
A single sklearn pipeline that works unchanged as SageMaker Processing is a direct, practical answer to 'how do you avoid train/serve skew' — a question that comes up in nearly every ML systems interview.
Path: 3 — ML Engineering on AWS Position: 3 of 12 Difficulty: 🟡 (local pipeline works without AWS; SageMaker Processing is the scale step) Time: 4-5h local, +1h with SageMaker Processing AWS Cost: ~$0.10 per Processing Job (ml.m5.large, ~5 minutes)
Agent Pickup Instructions
# Bootstrap
cd projects/confident-prep/p3-03-feature-engineering
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Download dataset (same as p3-01 and p3-02)
mkdir -p data
curl -o data/raw.csv \
"https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data"
# Run locally
python src/main.py --input data/raw.csv --output data/processed.csv --save-pipeline pipeline.joblib
# Run tests
pytest tests/ -v
# Submit to SageMaker Processing (requires AWS env vars)
python src/sagemaker_processor.py
Done when:
-
python src/main.py --input data/raw.csv --output data/processed.csvexits 0 and writesdata/processed.csv -
data/processed.csvhas no NaN values (verify withpython -c "import pandas as pd; assert not pd.read_csv('data/processed.csv').isna().any().any()") -
pipeline.joblibis written when--save-pipelineis passed - Loaded pipeline can transform new data without re-fitting
-
pytest tests/ -vshows 4/4 passing -
bash scripts/teardown.shremoves S3 Processing output prefix
What this project is
A reusable sklearn preprocessing Pipeline for the UCI Adult Income dataset. The pipeline applies median imputation to numeric columns, most-frequent imputation to categorical columns, standard scaling to numeric columns, and one-hot encoding to categorical columns — all composed via a ColumnTransformer. Data validation runs before AND after transformation. The fitted pipeline is serialized with joblib. The same main.py runs locally and as a SageMaker Processing Job.
What the learner achieves
"I can build a reproducible sklearn preprocessing pipeline, validate its inputs and outputs, serialize it, and run it identically on a local machine and in SageMaker Processing — so preprocessing is never ad-hoc."
Folder structure
p3-03-feature-engineering/
├── data/
│ ├── raw.csv # UCI Adult data (renamed from adult.data)
│ └── processed.csv # output of pipeline
├── src/
│ ├── main.py # CLI: --input, --output, [--save-pipeline]
│ ├── pipeline.py # build_pipeline, fit_and_transform, transform
│ ├── validator.py # validate_input (schema), validate_output (no NaN, shape)
│ └── sagemaker_processor.py # submit main.py to SageMaker SKLearnProcessor
├── scripts/
│ └── teardown.sh # delete S3 Processing Job output prefix
├── tests/
│ ├── test_pipeline.py # no NaN output, joblib roundtrip, shape non-zero
│ └── test_validator.py # missing column raises ValueError, NaN output raises ValueError
├── .env.example
├── requirements.txt
└── README.md
.env.example
# AWS region (needed for SageMaker Processing only)
AWS_REGION=us-east-1
# S3 bucket for Processing Job input and output
S3_BUCKET=my-ml-training-bucket
# IAM role ARN that SageMaker will assume
SAGEMAKER_ROLE_ARN=arn:aws:iam::123456789012:role/SageMakerExecutionRole
# Local paths (no AWS required)
LOCAL_DATA_DIR=data/
LOCAL_PIPELINE_PATH=pipeline.joblib
requirements.txt
scikit-learn==1.6.0
pandas==2.2.3
numpy==2.2.1
joblib==1.4.2
boto3==1.35.93
sagemaker==2.236.0
python-dotenv==1.0.1
pytest==8.3.4
src/ — what to implement
src/pipeline.py
Column lists (module-level constants):
NUMERIC_FEATURES = ["age", "fnlwgt", "education-num",
"capital-gain", "capital-loss", "hours-per-week"]
CATEGORICAL_FEATURES = ["workclass", "education", "marital-status",
"occupation", "relationship", "race",
"sex", "native-country"]
TARGET_COLUMN = "income"
build_pipeline() -> sklearn.pipeline.Pipeline
- Creates a
ColumnTransformerwith two branches:numeric:Pipeline([("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler())])categorical:Pipeline([("imputer", SimpleImputer(strategy="most_frequent")), ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False))])
- Wraps
ColumnTransformerin a top-levelPipeline(no estimator — preprocessing only) - Returns the unfitted pipeline
fit_and_transform(pipeline: Pipeline, X: pd.DataFrame) -> np.ndarray
- Calls
pipeline.fit_transform(X) - Returns the resulting numpy array
- Does NOT modify
pipelinein place beyond whatfit_transformdoes
transform(pipeline: Pipeline, X: pd.DataFrame) -> np.ndarray
- Calls
pipeline.transform(X)— pipeline must already be fitted - Raises
sklearn.exceptions.NotFittedError(propagated naturally) if called before fitting
get_feature_names(pipeline: Pipeline) -> list[str]
- Extracts output feature names from the fitted ColumnTransformer
- Returns a flat list of strings:
["age", "fnlwgt", ..., "workclass_Federal-gov", ...] - Uses
pipeline.named_steps['preprocessor'].get_feature_names_out()
src/validator.py
REQUIRED_COLUMNS: list[str] — same column names as p3-01/p3-02 (all 14 feature columns, excluding income)
validate_input(df: pd.DataFrame, expected_columns: list[str] | None = None) -> None
- If
expected_columnsis None, usesREQUIRED_COLUMNS - Checks that every column in
expected_columnsappears indf.columns - Raises
ValueError(f"Missing columns: {missing_cols}")if any are absent - Checks that df has at least 1 row; raises
ValueError("DataFrame is empty")if not - Does NOT modify the DataFrame
validate_output(arr: np.ndarray) -> None
- Checks
arr.ndim == 2; raisesValueError("Output must be 2D array")if not - Checks
arr.shape[0] > 0; raisesValueError("Output has zero rows")if not - Checks
not np.isnan(arr).any(); raisesValueError("Output contains NaN values after transformation")if NaN found - Does NOT check dtype (float32 and float64 are both valid)
src/main.py
CLI arguments:
--input(str, required) — path to raw CSV--output(str, required) — path to write processed CSV--save-pipeline(str, optional) — path to save fitted joblib pipeline
load_raw(input_path: str) -> tuple[pd.DataFrame, pd.Series]
- Re-uses loader logic from p3-02 (or copies it inline — do not import from p3-02)
- Returns
(X, y)whereXexcludesincomecolumn,yis the target
main()
- Parse args
- Load raw data with
load_raw validate_input(X)— raises on schema errorsbuild_pipeline()→fit_and_transform(pipeline, X)→ producesprocessed_arrvalidate_output(processed_arr)— raises on NaN or shape issues- Convert
processed_arrto DataFrame usingget_feature_names; attachyas last column - Write to
--outputas CSV (index=False) - If
--save-pipelinegiven:joblib.dump(pipeline, args.save_pipeline) - Print:
Processed {n_rows} rows → {n_cols} features. Saved to {output} - If
--save-pipelinegiven: printPipeline saved to {path}
Edge cases:
- If
--outputdirectory does not exist, create it withos.makedirs(..., exist_ok=True)
src/sagemaker_processor.py
CLI: python src/sagemaker_processor.py [--instance-type ml.m5.large] [--input-s3 s3://...] [--output-s3 s3://...]
upload_input_data(local_path: str, bucket: str, prefix: str) -> str
- Uploads
raw.csvto S3; returns S3 URI
submit_processing_job(input_s3: str, output_s3: str, instance_type: str) -> str
- Creates
sagemaker.sklearn.SKLearnProcessor(framework_version="1.2-1", ...) - Calls
.run(code="src/main.py", inputs=[ProcessingInput(...)], outputs=[ProcessingOutput(...)], wait=True) - Returns the processing job name
download_output(job_name: str, local_dir: str) -> None
- Downloads processed CSV from S3 output to local_dir
- Prints:
Downloaded processed data to {local_dir}
tests/ — what to test
tests/test_pipeline.py
test_pipeline_output_has_no_nan
- Load a 50-row slice of
adult.dataas DataFrame with correct columns - Inject
np.naninto several cells (simulate?replacement) - Build pipeline, fit_and_transform
- Assert
not np.isnan(result).any()
test_pipeline_output_shape_is_non_zero
- Same setup as above
- Assert
result.shape[0] == 50(rows preserved) - Assert
result.shape[1] > 14(OHE expands categorical columns)
test_joblib_roundtrip_preserves_transform
- Fit pipeline on 100-row split
- Save with
joblib.dumpto tmp file - Load with
joblib.load - Transform a new 10-row split with the loaded pipeline
- Assert output has no NaN and correct row count
test_local_and_sagemaker_use_same_script
- Assert that
src/main.pyexists - Assert the file contains
--input(argparse arg used by both local and SageMaker invocations) - Assert the file contains
validate_inputcall
tests/test_validator.py
test_validate_input_raises_on_missing_column
- Create DataFrame missing the
agecolumn - Assert
validate_input(df)raisesValueErrorcontaining"age"
test_validate_output_raises_on_nan
- Create a 2D numpy array with one NaN cell
- Assert
validate_output(arr)raisesValueErrorcontaining"NaN"
README.md content
# P3-03 — Feature Engineering
Reusable sklearn preprocessing pipeline for UCI Adult Income. Runs locally and as SageMaker Processing.
## Prerequisites
- Python 3.11+
- AWS credentials — only needed for SageMaker Processing step
## Quick start (local only)
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
mkdir -p data && curl -o data/raw.csv \
"https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data"
python src/main.py --input data/raw.csv --output data/processed.csv \
--save-pipeline pipeline.joblib
What the pipeline does
- Numeric columns: median imputation → StandardScaler
- Categorical columns: most_frequent imputation → OneHotEncoder (handle_unknown=ignore)
- ColumnTransformer combines both branches
- Validates input schema before fitting, validates output (no NaN) after
Verify no NaN in output
python -c "import pandas as pd; df=pd.read_csv('data/processed.csv'); print('NaN:', df.isna().sum().sum())"
Run as SageMaker Processing Job
cp .env.example .env # fill in S3_BUCKET and SAGEMAKER_ROLE_ARN
python src/sagemaker_processor.py
Tests
pytest tests/ -v
Teardown
bash scripts/teardown.sh
---
## GUIDE.md content
```markdown
# Guide — P3-03 Feature Engineering
## Why a Pipeline, not manual steps
Without a Pipeline, you must remember to apply the same scaling and encoding at inference time
that you applied at training time. Forget once and your model receives unscaled inputs — it
silently produces wrong predictions. With a fitted Pipeline serialized to joblib, you call
`pipeline.transform(new_data)` at inference and the exact same transformations apply.
## The ColumnTransformer explained
ColumnTransformer applies different transformations to different column subsets and concatenates
the results horizontally. Numeric columns become scaled floats. Categorical columns become
a sparse binary matrix (one column per category value). The total output width is
`len(NUMERIC_FEATURES) + sum(unique_values_per_categorical_column)`.
For UCI Adult, expect roughly 6 numeric + ~100 OHE columns = ~106 features total.
## handle_unknown="ignore" matters
At inference time your model may see a `native-country` value that was not in training data.
With `handle_unknown="ignore"`, OneHotEncoder outputs a zero vector for that value instead of
raising an exception. This is the correct production behavior.
## Validation before and after
**Before:** catches schema drift — if the incoming data has a renamed column, you fail early
with a clear message rather than propagating wrong data into the model.
**After:** catches imputation bugs — if your imputer silently failed to fill NaN (e.g., because
the column dtype was wrong), the post-transform check will catch it.
## SageMaker Processing vs local
SageMaker Processing mounts your input from S3 at `/opt/ml/processing/input/` and collects
your output from `/opt/ml/processing/output/`. Your `main.py` uses `--input` and `--output`
arguments — the SKLearnProcessor passes the mounted paths automatically. The script does not
need to know it is running in a container.
## Interview framing
"I build preprocessing as a serialized sklearn Pipeline so the same transformations apply
identically at training and inference time — no manual step synchronization, no silent
preprocessing drift between training and production."
Failure modes to handle
| Failure | Where | How to handle |
|---|---|---|
Column with all-NaN values breaks most_frequent imputer |
pipeline.py: build_pipeline |
SimpleImputer(strategy="most_frequent") returns NaN for all-NaN column — post-transform validate_output will catch it |
| OHE sees unknown category at inference | pipeline.py |
handle_unknown="ignore" on OneHotEncoder outputs zero vector instead of error |
| Schema mismatch (column renamed upstream) | validator.py: validate_input |
Raise ValueError listing the missing columns before any transformation |
--output directory does not exist |
main.py: main |
os.makedirs(dirname, exist_ok=True) before writing CSV |
| SageMaker Processing Job fails | sagemaker_processor.py |
Catch Exception from .run(), print failure reason from job description |
joblib.load on wrong Python version |
tests/test_pipeline.py note |
Document in README: pipeline files are not portable across Python major versions |
| Post-transform NaN (imputer edge case) | validator.py: validate_output |
Raise with message identifying which step likely failed |
The metric this project measures
What: Output feature count after one-hot encoding (indicator of pipeline correctness)
Format: Integer, printed as Processed {n_rows} rows → {n_cols} features
Target: For UCI Adult Income: approximately 100-110 features (6 numeric + ~100 OHE columns). Exact number depends on unique values in categorical columns in the training split.
Secondary metric: NaN count in output (must be 0), printed as NaN cells in output: 0
Cost estimate
| Resource | Qty | Rate | Estimated cost |
|---|---|---|---|
| SageMaker Processing Job (ml.m5.large, ~5 min) | 1 run | $0.115/hr | ~$0.01 |
| S3 storage (raw + processed CSV, ~10 MB) | 1 session | $0.023/GB-month | <$0.01 |
| Total per session | ~$0.10 |
Teardown checklist
scripts/teardown.sh must:
- Delete
s3://${S3_BUCKET}/p3-03/input/prefix - Delete
s3://${S3_BUCKET}/p3-03/output/prefix - Print
Teardown complete.
The quiz isn't written yet
The project itself is ready to build — the repo, the spec and the deployment steps are all live. What's missing is the written quiz that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.
The assignment isn't written yet
The project itself is ready to build — the repo, the spec and the deployment steps are all live. What's missing is the written assignment that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.