Debugging and Testing ML Systems Questions
Finding, diagnosing, and fixing problems in ML code, data, and models, and building tests that catch these problems before they reach users. Covers common ML pitfalls (data leakage, shape mismatches, silent training bugs, mis-specified loss or metrics), root-cause analysis of model regressions and production incidents (accuracy drops, calibration drift, intermittent or hard-to-reproduce failures), distributed-training-specific failures (multi-GPU divergence, intermittent OOM, precision-related instability), and the diagnostic tooling that supports it (reproducibility artifacts, structured logging, instrumentation). Also covers testing ML systems directly: unit tests for data and feature pipelines, validation checks for datasets and features, test oracles and acceptance criteria for probabilistic or non-deterministic model outputs, and integration and regression tests that catch model or pipeline regressions before deployment. Emphasizes the engineering rigor that keeps ML systems correct and maintainable.
Explain how layers like BatchNorm and Dropout, and data transforms like random crop, behave differently between training and inference. Describe a concrete bug scenario where a team forgets to switch a model to evaluation mode before serving it, what symptom that would produce in production (e.g. degraded, inconsistent, or slowly-drifting predictions), and how you would catch this specific class of bug in a pre-deploy test rather than discovering it in production.
Sample Answer
Direct answer. BatchNorm and Dropout are two of the few common layer types that behave differently depending on whether the model is in training or evaluation mode, and calling a model in the wrong mode produces predictions that are silently wrong rather than an error, which is what makes this bug dangerous.
What actually differs. In training mode, BatchNorm normalizes each batch using that batch's own mean and variance (and updates a running estimate for later use), so its output depends on which other examples happen to be in the same batch. In evaluation mode, it instead uses the running statistics accumulated during training, making its output depend only on the single input, not on whatever else is in the batch. Dropout randomly zeroes activations during training and is a no-op during evaluation. Data transforms like random crop (taking a randomly-positioned sub-region of each training image, so the model sees varied framings of the same content instead of one fixed view) are usually applied only during training as augmentation and skipped at evaluation and inference time, where you use a fixed centre crop or the whole image, though that's a data-pipeline choice rather than a framework-enforced mode switch.
Concrete bug scenario. A team trains a model, saves the checkpoint, and loads it into a serving process, but forgets to call model.eval() before running inference (the model defaults to training mode when loaded). In production this causes two problems at once: predictions become non-deterministic and batch-dependent (two identical requests batched differently can get different scores), and Dropout randomly zeroes part of the network's activations on every request, systematically degrading accuracy compared to the trained model's true capability. Batch size 1, which low-latency single-request serving hits constantly, deserves stating precisely rather than being waved at as "erratic", because it does not degrade gracefully in either direction. A batch of one has no within-batch spread to normalize by, so a 1D-feature model does not silently misbehave at all: PyTorch refuses outright with ValueError: Expected more than 1 value per channel when training, turning a silent accuracy problem into a hard serving error. A convolutional model does not raise, because it still has height times width values per channel, but it normalizes every channel to exactly zero mean, which erases the input signal and pushes the model toward a constant output. Both behaviours are printed by the script below rather than asserted.
Verified concretely. The script below builds a small model containing both mode-dependent layers, calls it twice on the identical input batch in each mode, and then exercises the quieter half of the bug: BatchNorm's running statistics keep being rewritten by whatever traffic passes through while the model is in training mode.
"""
Shows the train-vs-eval mode difference concretely, and the running-statistic
drift that makes this bug present as slow drift rather than only as noise.
Pinned: torch manual_seed(0), model = Linear(16, 32) -> BatchNorm1d(32)
-> Dropout(0.5) -> Linear(32, 1), input batch of shape (8, 16) drawn from a
standard normal. Run with: python3 eval_mode_check.py
"""
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(16, 32),
nn.BatchNorm1d(32),
nn.Dropout(0.5),
nn.Linear(32, 1),
)
x = torch.randn(8, 16)
model.train()
out_a = model(x)
out_b = model(x)
print("=== training mode (the bug): same input, two calls ===")
print(f"typical output magnitude (mean abs of call 1): {out_a.abs().mean().item():.4f}")
print(f"max abs difference between the two calls: {(out_a - out_b).abs().max().item():.4f}")
model.eval()
out_c = model(x)
out_d = model(x)
print("\n=== eval mode (correct): same input, two calls ===")
print(f"typical output magnitude (mean abs of call 1): {out_c.abs().mean().item():.4f}")
print(f"max abs difference between the two calls: {(out_c - out_d).abs().max().item():.6f}")
# --- running statistics keep updating in training mode, which is the slow-drift
# mechanism: serving traffic silently rewrites BatchNorm's stored mean/variance.
bn = model[1]
print("\n=== BatchNorm running mean drift under 200 serving batches in training mode ===")
start = bn.running_mean.clone()
model.train()
with torch.no_grad():
for _ in range(200):
model(torch.randn(8, 16) + 0.5) # production traffic, mildly shifted
drifted = bn.running_mean.clone()
print(f"mean abs change in BatchNorm running_mean: {(drifted - start).abs().mean().item():.4f}")
model.eval()
out_e = model(x)
print(f"max abs change in eval-mode output on unchanged input: {(out_e - out_c).abs().max().item():.4f}")
# --- what actually happens at batch size 1, which single-request serving hits ---
print("\n=== batch size 1 in training mode ===")
model.train()
try:
model(torch.randn(1, 16))
print("1D-feature model at batch size 1: returned a value")
except ValueError as exc:
print(f"1D-feature model at batch size 1 raises ValueError: {exc}")
conv_bn = nn.BatchNorm2d(3)
conv_bn.train()
conv_out = conv_bn(torch.randn(1, 3, 8, 8))
print(
"conv model at batch size 1 does not raise; per-channel output means: "
f"{[round(v, 6) for v in conv_out.mean(dim=(0, 2, 3)).tolist()]}"
)
Actual output from running this script:
=== training mode (the bug): same input, two calls ===
typical output magnitude (mean abs of call 1): 0.7291
max abs difference between the two calls: 1.4462
=== eval mode (correct): same input, two calls ===
typical output magnitude (mean abs of call 1): 0.2517
max abs difference between the two calls: 0.000000
=== BatchNorm running mean drift under 200 serving batches in training mode ===
mean abs change in BatchNorm running_mean: 0.1948
max abs change in eval-mode output on unchanged input: 0.7940
=== batch size 1 in training mode ===
1D-feature model at batch size 1 raises ValueError: Expected more than 1 value per channel when training, got input size torch.Size([1, 32])
conv model at batch size 1 does not raise; per-channel output means: [0.0, 0.0, 0.0]
Reading the numbers: in training mode the two calls on the SAME input differ by up to 1.4462, against a typical output magnitude of 0.7291, so the run-to-run disagreement is about twice the size of the outputs themselves, not a rounding-level wobble; in eval mode the same two calls agree to 0.000000. That is the first symptom a team would see: identical requests silently returning different scores.
Why this can also present as slow drift, not just noise. BatchNorm in training mode does not merely normalize by the current batch, it also updates the running mean and variance it will use later in eval mode. A model served in training mode is therefore letting live production traffic quietly rewrite its own normalization statistics. In the run above, after 200 batches of mildly shifted "production" traffic, the running mean had moved by 0.1948 on average, and the eval-mode prediction for an input that never changed moved by up to 0.7940 (against a typical eval-mode output magnitude of 0.2517). Nothing about the weights, the input, or the code changed over those 200 batches; the model's behaviour crept anyway. This is why the same bug can look like day-over-day drift in a dashboard rather than like obvious per-request noise, and why it can survive a spot check that only compares two responses a second apart.
And the batch-size-1 case is a third, different presentation. The last block of output confirms both halves of it: the 1D-feature model raises rather than mispredicting, so single-request traffic fails loudly while batched traffic keeps returning quietly wrong numbers, and the convolutional model returns per-channel means of exactly 0.0, which is the signal being erased rather than merely perturbed. It is worth knowing which of the two your architecture gives you, because they need different alerts: one shows up in the error rate, the other only in the prediction distribution.
How to catch this before it reaches production, rather than discovering it in an incident. Add a pre-deploy test that loads the serialized model artifact exactly as the serving code does, and asserts two things: (1) model.training is False immediately after load (or, more robustly, that the serving wrapper explicitly calls .eval() right after loading rather than relying on load-time defaults), and (2) calling the model twice on the same input batch produces IDENTICAL outputs. That second assertion is the one that actually catches this bug class even if a future refactor removes the explicit .eval() call somewhere else in the code, since it tests the observable behavior directly rather than trusting a specific line of code to always be there.
Implement a function validate_events(events, schema) in Python that takes a list of event dictionaries and a schema dictionary (for example {'user_id': {'type': 'int', 'required': True}, 'amount': {'type': 'float', 'min': 0}, 'ts': {'type': 'timestamp', 'format': 'iso8601', 'required': True}}) and returns (valid_events, errors). The function must validate types, required fields, timestamp format, and numeric ranges. Discuss what error information you would include for each invalid record to make debugging a failed validation run fast.
Sample Answer
Direct answer. The function needs to check each record against the schema independently, collect every violation (not just fail on the first one), and return both the clean records and enough detail on each rejected record to debug it without re-running anything.
Approach. Iterate records; for each field in the schema, check presence (if required), then type, then any numeric range constraint, then timestamp format if applicable. Accumulate all violations for a record before deciding whether it's valid, since a record can fail multiple checks and you want to see all of them in one pass rather than fixing one bug at a time across repeated runs.
from datetime import datetime
def validate_events(events, schema):
valid_events, errors = [], []
for idx, ev in enumerate(events):
ev_errors = []
for field, rules in schema.items():
present = field in ev and ev[field] is not None
if rules.get('required') and not present:
ev_errors.append(f"{field}: missing required field")
continue
if not present:
continue
val = ev[field]
ftype = rules.get('type')
if ftype == 'int':
if not isinstance(val, int) or isinstance(val, bool):
ev_errors.append(f"{field}: expected int, got {type(val).__name__}")
elif ftype == 'float':
if not isinstance(val, (int, float)) or isinstance(val, bool):
ev_errors.append(f"{field}: expected float, got {type(val).__name__}")
elif 'min' in rules and val < rules['min']:
ev_errors.append(f"{field}: {val} below minimum {rules['min']}")
elif ftype == 'timestamp':
if rules.get('format') == 'iso8601':
if not isinstance(val, str):
ev_errors.append(f"{field}: expected ISO8601 string, got {type(val).__name__}")
else:
try:
datetime.fromisoformat(val.replace('Z', '+00:00'))
except ValueError:
ev_errors.append(f"{field}: '{val}' is not valid ISO8601")
if ev_errors:
errors.append({"index": idx, "record": ev, "errors": ev_errors})
else:
valid_events.append(ev)
return valid_events, errors
Run against 5 example records, one clean and one carrying each of the four defects the schema can detect:
SCHEMA = {
'user_id': {'type': 'int', 'required': True},
'amount': {'type': 'float', 'min': 0},
'ts': {'type': 'timestamp', 'format': 'iso8601', 'required': True},
}
EVENTS = [
{'user_id': 1, 'amount': 9.99, 'ts': '2026-07-01T10:00:00Z'}, # 0: clean
{'user_id': 'abc', 'amount': 4.50, 'ts': '2026-07-01T10:05:00Z'}, # 1: user_id is a str, not an int
{'user_id': 3, 'amount': 12.00}, # 2: required ts absent entirely
{'user_id': 4, 'amount': -5, 'ts': '2026-07-01T10:15:00Z'}, # 3: amount below the min of 0
{'user_id': 5, 'amount': 7.25, 'ts': 'not-a-date'}, # 4: ts present but unparseable
]
valid, errs = validate_events(EVENTS, SCHEMA)
print("valid count:", len(valid))
print("error count:", len(errs))
for e in errs:
print(f" idx {e['index']} -> {e['errors']}")
This executes and produces:
valid count: 1
error count: 4
idx 1 -> ['user_id: expected int, got str']
idx 2 -> ['ts: missing required field']
idx 3 -> ['amount: -5 below minimum 0']
idx 4 -> ["ts: 'not-a-date' is not valid ISO8601"]
Each index is traceable back to exactly one rule in the schema. Record 2 is the one that exercises the required branch: ts is absent from the dict entirely, so present is False and rules.get('required') is True, which is the only combination that produces a missing required field message. Note that user_id is checked first (schema iteration follows the schema dict's insertion order), so record 2 passes the user_id check before failing on ts; the error names the field that is actually missing, not the first field in the schema.
One subtlety worth calling out explicitly: isinstance(val, int) alone would silently accept True/False as valid integers in Python, since bool is a subclass of int. The check above excludes booleans explicitly; skipping that guard is an easy way to let a boolean value slip through a numeric-type check undetected.
What error information to include for fast debugging. Each error entry above carries the record's index in the batch, the full original record (not just the field that failed, since a downstream engineer debugging a validation failure usually needs to see the whole record for context), and the list of every rule that record violated. Returning only "record 3 is invalid" without this detail turns every validation failure into a second investigation just to find out why; returning the field-level reasons up front means the caller (a human or an automated alert) can act immediately.
Implement a deterministic, group-aware train/validation/test split function in Python. Requirements: split by hashing a group key (e.g. customer_id) so records sharing a group never span splits; the split must be reproducible across runs; target approximate fractions of 0.7/0.15/0.15; and the function should run in roughly linear time in the number of rows. Describe the edge cases you would test for (e.g. a group larger than a whole split's target size, a very small number of distinct groups) and how you would verify no group leaks across splits.
Sample Answer
Direct answer. The split has to be decided at the GROUP level, not the row level: hash each group's key to a deterministic value in [0, 1), then bucket that value into train/val/test by fixed cutoffs. Every row belonging to a given group inherits that group's bucket, so no group can ever appear on both sides of a split.
import hashlib
def group_split(row_ids, group_keys, train_frac=0.7, val_frac=0.15, seed=42):
def bucket(group_key):
h = hashlib.md5(f"{seed}-{group_key}".encode()).hexdigest()
return int(h, 16) % 10000 / 10000.0
splits = {"train": [], "val": [], "test": []}
for rid, gk in zip(row_ids, group_keys):
b = bucket(gk)
if b < train_frac:
splits["train"].append(rid)
elif b < train_frac + val_frac:
splits["val"].append(rid)
else:
splits["test"].append(rid)
return splits
Reading the one line that does all the work. int(h, 16) % 10000 / 10000.0 is three steps. hashlib.md5(...).hexdigest() returns 32 hex characters, which int(h, 16) reads as one enormous integer somewhere in [0, 2128). MD5's defining property for this purpose is that its output is uniformly spread over that range and that a one-character change in the input moves it somewhere completely unrelated, so similar keys like cust_00041 and cust_00042 do not land near each other. % 10000 folds that huge integer down to an integer in [0, 9999], still uniformly (2128 is not a multiple of 10000, so there is a bias, but it is on the order of 10000 / 2**128 and is not measurable at any dataset size you will ever have). Dividing by 10000.0 turns that into a value in [0, 1) on a grid of 0.0001, which is exactly the resolution you need to compare against fractions like 0.70 and 0.85.
Also worth naming: seed here is a SALT, a string mixed into the hashed value, not the seed of a random number generator. Nothing in this function is random. Changing seed from 42 to 43 changes every key's hash and therefore reshuffles every group into a different split, which is the reproducible way to get a genuinely different split when you want one.
One key traced end to end (running the function with the default seed=42):
bucket("cust_00042"): md5("42-cust_00042") -> int % 10000 = 2616 -> 0.2616
0.2616 < 0.70 -> train, and EVERY row of cust_00042 goes to train
bucket("cust_01999"): md5("42-cust_01999") -> int % 10000 = 8030 -> 0.8030
0.8030 is >= 0.70 and < 0.85 -> val, and EVERY row of cust_01999 goes to val
That is the whole no-leakage guarantee in two lines: the bucket is a function of the group key alone, so two rows sharing a key cannot be sent anywhere different.
This is O(n) in the number of rows: each row does one constant-time MD5 computation on a short string, with no sorting and no grouping pass beforehand. There is no cache and no lookup table here, the hash is recomputed per row; if that cost ever matters, memoize bucket on the group key, which turns it into one hash per distinct group instead of one per row without changing any assignment.
How you verify no group leaks across splits. Reconstruct, for every group, the set of splits its rows actually landed in, and assert that set has size 1. The harness below does that on 20,000 rows across 2,000 groups, and also calls the function twice to check reproducibility. The row-to-customer assignment is itself seeded, otherwise the realized counts move every run and the output below would not be reproducible.
import random
from collections import defaultdict
rnd = random.Random(0) # seeded so the synthetic data is reproducible
groups = ["cust_%05d" % i for i in range(2000)]
group_keys = [rnd.choice(groups) for _ in range(20000)]
row_ids = list(range(20000))
splits = group_split(row_ids, group_keys)
n = len(row_ids)
print({k: (len(v), round(len(v) / n, 4)) for k, v in splits.items()})
where = {rid: name for name, ids in splits.items() for rid in ids}
per_group = defaultdict(set)
for rid, gk in zip(row_ids, group_keys):
per_group[gk].add(where[rid])
print("groups spanning multiple splits:", sum(1 for s in per_group.values() if len(s) > 1))
print("identical on a second call:", group_split(row_ids, group_keys) == splits)
{'train': (13644, 0.6822), 'val': (3006, 0.1503), 'test': (3350, 0.1675)}
groups spanning multiple splits: 0
identical on a second call: True
Zero groups span multiple splits, and calling the function twice with the same salt produces identical splits. The realized fractions (68.2% / 15.0% / 16.8%) are close to, but not exactly, the requested 0.7/0.15/0.15: this is expected and honest, not a bug. Hashing 2,000 discrete groups into continuous buckets has natural sampling variance, and the approximation gets tighter as the number of distinct groups grows. Here the test split runs about 1.8 percentage points hot and train about 1.8 points light, purely because of where those 2,000 particular keys happened to hash. Note also that the group keys are part of the input to the hash, so renaming them (for instance dropping the zero padding to cust_42) reassigns every group and shifts these fractions by a couple of points in either direction. That is not instability in the method, it is the same determinism working as designed.
Edge cases to test explicitly.
- A single group larger than an entire split's target size (e.g. one customer with 40% of all rows): this group still lands entirely in one split, which can make that split's realized size badly overshoot its target. Worth a dedicated test asserting the function doesn't silently truncate or split a large group's rows, since correctness (no leakage) matters more than hitting the target fraction exactly.
- Very few distinct groups (say, fewer than 20): the discrete hash-bucket assignment can produce split fractions far from the targets purely from small-sample variance; a test here should assert NO LEAKAGE holds (the correctness property) rather than asserting the fractions are close to target (a property this method doesn't guarantee at small group counts).
- A row with a null or missing group key: decide explicitly whether to route it to a fixed split (e.g. always train) or reject it, and test that the choice is applied consistently rather than left to whatever
hash(None)happens to produce. - Determinism across process restarts: since Python's built-in
hash()is randomized per-process by default (unlikehashlib.md5, which is what this implementation deliberately uses), a test asserting two separate process invocations with the same salt produce identical splits is exactly what would catch a regression if someone "simplified" this to usehash()instead.
A production model returns predictions that differ from what the training notebook produced for the same inputs. Outline a systematic debugging checklist covering: code differences between the notebook and the deployed artifact, data-schema drift, environment and package-version differences, random seeds, feature-preprocessing mismatches, and model-artifact versioning. Indicate which checks are quick to run first and which require deeper investigation, and explain why this differs from a generic 'my model regressed' investigation.
Sample Answer
Direct answer
When a deployed model's predictions differ from what the training notebook produced for the same inputs, the bug is almost never "the model regressed" in the usual sense: nothing about the model's learned parameters or the underlying data relationship needs to have changed at all for this to happen. The mismatch lives in the gap between the notebook environment and the deployed artifact, so the checklist is about parity, not about drift: same artifact, same behavior on a fixed canned input, same environment and package versions, same seeds, same schema, same preprocessing, same code, checked in that rough order from cheapest to most expensive to verify. The table below lists the checks in exactly that order, and its Speed column marks where the quick checks end and the deep ones begin.
Structured elaboration
Why this is a different investigation from a generic "my model regressed" one. A generic regression investigation assumes the deployed system is internally consistent and asks whether the world changed around it (new input distribution, label drift, a genuine change in the feature-label relationship). This scenario gives you something stronger to work with: a fixed set of inputs and two different outputs from what is supposed to be the same model, which means the cause is a MECHANICAL inconsistency somewhere in how "the same model" was built, packaged, or run, not a question about whether the world moved. That reframes the entire search: instead of statistical drift tests, you want diffs, hashes, and version comparisons, because the answer is a discrete "this one thing differs," not a distributional judgment call.
The checklist, ordered from quick to deep:
| Check | What it catches | Speed |
|---|---|---|
| Model-artifact version and hash | Wrong artifact deployed, or a stale cached copy served instead of the intended one | Quick: compare a checksum or registry version identifier, no data needed |
| Canned-input sanity check | Confirms there IS a discrepancy at all, and on which inputs | Quick: run 3 to 5 fixed example rows from the notebook through the deployed endpoint and diff outputs |
| Package and runtime versions | A library upgrade changed numerical behavior or default arguments between notebook and deployment image | Quick to check versions; can be deep to root-cause if a specific library's internals changed |
| Random seeds and determinism flags | Any stochastic operation in preprocessing or inference (dropout left active, non-deterministic kernel selection) | Quick to inspect config; needs a controlled re-run to confirm impact |
| Data-schema drift | A feature that changed name, type, or ordering between what the notebook assumed and what deployment now receives | Medium: requires diffing the schema the deployed pipeline actually sees against notebook assumptions |
| Feature-preprocessing mismatches | Preprocessing code duplicated (not shared) between notebook and deployment, and the two copies drifted apart, or a fit-time statistic (a scaler's mean and standard deviation) computed differently in each path | Deep: requires diffing intermediate transformed feature values, not just raw inputs or final outputs |
| Code differences between notebook and deployed artifact | The deployed artifact was built from a different commit, branch, or manually-edited copy of the code than what produced the notebook's results | Deep: requires diffing the actual code paths, not just comparing final numbers |
Code differences between the notebook and the deployed artifact. Confirm the deployment build was produced from the exact commit the notebook ran against, not a branch that diverged afterward, and that no manual edits were made to either copy post-hoc. This sounds obvious but is a frequent real cause when a notebook is a one-off exploration rather than the actual training script that produced the deployed model; if the notebook and the "real" training script were never the same code to begin with, matching outputs was never guaranteed.
Data-schema drift. Snapshot the schema the deployed pipeline actually receives at inference time (field names, types, ordering, nullability) and diff it against what the notebook's training data assumed. A silently reordered feature vector, in particular, is a classic bug: if preprocessing does not explicitly key features by name and instead relies on positional ordering, a schema reorder upstream produces confidently wrong predictions with no error thrown anywhere.
Environment and package-version differences. Compare the full dependency list (not just top-level packages; transitive dependencies matter too) between the notebook's environment and the deployment image, with particular attention to numerical libraries, since a minor version bump can change default numerical behavior (rounding mode, a changed default for a normalization epsilon) without changing any API signature.
Random seeds. Confirm every source of randomness in the path (data shuffling if it affects any online preprocessing step, dropout or other stochastic layers if inference-time stochasticity was accidentally left enabled, any sampling in postprocessing) is seeded identically, or better, that inference-time randomness is eliminated entirely rather than merely seeded, since seeded-but-still-stochastic paths are fragile to reorder.
Feature-preprocessing mismatches. The highest-value deep check: dump the fully transformed feature vector (post-preprocessing, pre-model) for the same canned inputs from both the notebook path and the deployed path, and diff them directly rather than only comparing final predictions. This localizes the discrepancy to a specific transformation step instead of leaving you to guess from the final output alone.
Model-artifact versioning. Confirm the model file's checksum or registry version matches what the notebook actually produced and saved, not a subsequent retrain, an accidentally-cached older artifact, or a serialization format conversion (for example exporting to an inference-optimized format) that was assumed to be lossless but was not verified to be.
Worked example
Suppose the canned-input sanity check confirms a real discrepancy: three fixed rows produce different predicted probabilities in the notebook versus the deployed endpoint, with the deployed endpoint consistently higher. The model-artifact hash matches (same file), and package versions match. The schema diff shows no difference in field names or types, but dumping the fully transformed feature vectors reveals that one numeric feature is scaled differently: the notebook's preprocessing used a StandardScaler-style transform fit on the training set's mean and standard deviation, while the deployed pipeline recomputes its own mean and standard deviation from a small rolling window of recent traffic on every batch, because the two paths implement scaling with separately-maintained code rather than sharing a single serialized preprocessing artifact. That is a feature-preprocessing mismatch specifically, not a schema, seed, or artifact problem, and the fix is to serialize the fitted scaler alongside the model artifact and load that exact object in both the notebook (for any future comparison) and the deployed pipeline, eliminating the duplicated, independently-drifting implementation.
Trade-offs and pitfalls
The most common wrong turn is comparing only final predictions and stopping once a difference is confirmed, without dumping intermediate transformed features. Final-output comparison tells you THAT something differs, not WHERE, and for anything past the quick checks (schema, preprocessing, code) you need the intermediate values to localize the cause efficiently rather than guessing.
A second pitfall is treating this investigation as interchangeable with a production-regression investigation and reaching for distributional drift tests first. Those tests are the right tool when the question is "did the world change," but here you have a much stronger starting fact (same claimed inputs, same claimed model, different outputs), and reaching for statistical tests before reaching for hashes and diffs wastes time on a question you have not actually been asked.
A third pitfall, once the root cause is found and fixed: treat the underlying duplication (two independently-maintained implementations of "the same" preprocessing) as the real defect, not just the one bug it produced this time. If the notebook and the deployed pipeline can drift apart once, on one feature, they can drift apart again, on a different feature, unless the artifact itself (not just a description of the transform) is what gets shared between training and serving.
A code-generation feature in your product sometimes produces incorrect or insecure code. Describe a testing and CI/CD strategy to catch regressions before deployment: unit tests, property-based tests, static analyzers, execution sandboxes, and fuzzing, plus staged rollout practices. Explain how you would define automatic blocking criteria (what specifically fails the pipeline) rather than relying on manual review alone.
Sample Answer
Direct answer
Treat every snippet the code-generation model produces as untrusted input, not as trusted code, and run it through five layers before it ever reaches a user: unit tests against a fixed golden-task suite, property-based tests against the function's declared contract, static analysis for banned or dangerous patterns, execution in an isolated sandbox, and fuzzing with malformed inputs. Gate deployment behind a staged rollout with automatic blocking criteria: objective, machine-checkable rules that fail the pipeline without a human in the loop. Manual review still has a place, but only for the judgment calls a computer cannot make (style, architectural fit), never as the backstop for correctness or security.
Structured elaboration
1. Unit tests (a fixed golden-task suite). Maintain a curated set of prompts with known-correct reference behavior: "write a function that parses this date format," "implement binary search," with a fixed set of input/output assertions per task. Every model version and every prompt-template change re-runs the whole suite. This is the cheapest, fastest-running layer and the one that should run on every single generation attempt, not just before a release.
2. Property-based tests against the generated code's declared contract. Unit tests only check the cases you thought of. If the generation request specifies a contract ("sort this list," "this function is idempotent," "this parser never crashes on malformed input"), generate hundreds of random inputs with a library such as Hypothesis and check the property holds for all of them, not just a handful of examples. This is the layer that catches "works on the demo input, wrong in general."
3. Static analyzers. Before any generated code executes anywhere, scan its abstract syntax tree (AST, the parsed tree structure of the code) for a deny-list of dangerous constructs: eval/exec, shelling out with shell=True, deserializing untrusted data with pickle.loads, hardcoded credentials, network calls to non-allow-listed hosts. This layer is cheap (milliseconds, no execution needed) and catches the class of bug that is dangerous specifically because it might succeed.
4. Execution sandboxes. Run the candidate code in an isolated environment: no network egress, a restricted filesystem (or none), a memory/CPU/wall-time budget, and syscall-level isolation, not just a Python try/except. Syscall-level isolation means the restriction is enforced by the operating system on the requests the process makes of the kernel (open this file, open this socket), not by the language runtime the untrusted code is already running inside. In practice that means a container with a restricted seccomp profile (seccomp is a Linux facility that takes an explicit allow-list of system calls a process may make, and kills or errors the process on anything outside it) or a microVM (a stripped-down virtual machine that boots in tens of milliseconds and runs its own kernel, so an escape has to get through a hypervisor boundary rather than just a shared-kernel namespace). A try/except catches exceptions inside the interpreter; it does nothing to stop code that successfully calls out to the network or writes to disk. Capture stdout, stderr, and exit code from every run, since a silent non-crash with wrong output is the most common way this feature fails in practice, not a loud crash.
5. Fuzzing. Feed the sandboxed function malformed, boundary, and adversarial inputs, empty strings, huge inputs, unicode edge cases, deeply nested structures, using a fuzzing harness (for example Atheris for Python, which is coverage-guided: it uses code coverage feedback to steer input generation toward unexplored branches, rather than purely random inputs that tend to plateau quickly).
6. Staged rollout. Even after all five automated gates pass (unit tests, property-based tests, static analysis, sandbox execution, fuzzing), ship behind a feature flag: an internal canary first, then a small percentage of real traffic, with an automatic kill switch tied to error-rate and complaint-rate thresholds, not a human watching a dashboard. Rollback should be a config flip, not a redeploy. Staged rollout is a sixth stage but not a sixth automated gate: it runs after the merge decision, on real traffic, so it can only catch what the pre-merge gates missed.
Automatic blocking criteria (what specifically fails the pipeline)
The failure mode this question is really asking about is a pipeline whose only real gate is "someone eyeballs the diff." Replace that with named, binary, automatic conditions:
| Check | Blocking condition | Layer |
|---|---|---|
| Golden-task suite | Any assertion fails | Unit tests (layer 1) |
| Contract property test | Any counterexample found | Property-based tests (layer 2) |
| Static analyzer | Any deny-listed pattern matched (severity high) | Static analyzer (layer 3) |
| Sandbox execution | Non-zero exit code, any network egress attempt, any filesystem write outside the sandbox scratch dir, timeout exceeded | Execution sandbox (layer 4) |
| Fuzz harness | Any input in the corpus triggers an uncaught exception or a sandbox violation | Fuzzing (layer 5) |
| Latency / complexity budget | Generated function exceeds a fixed time or memory budget on the golden inputs | Cuts across layers 3 and 4 (a static complexity bound plus a measured wall-clock run), which is why the table has six rows for five layers |
Everything in that table is a yes/no computed by code, which is what makes it "automatic": no reviewer discretion is involved in a block decision. Manual review is routed only to changes that pass every one of these and still need a subjective call (does this match our house style, is this the right abstraction), which is an explicitly different, lower-stakes question than "is this safe to ship."
Worked example
Below is a minimal version of the static-analyzer layer: an AST-based gate that scans a candidate snippet for the highest-severity deny-listed patterns and returns a structured block/pass verdict.
import ast
BANNED_CALLS = {"eval", "exec", "compile"}
BANNED_MODULE_ATTRS = {
("os", "system"), ("subprocess", "Popen"), ("subprocess", "call"),
("subprocess", "run"), ("pickle", "loads"), ("pickle", "load"),
}
class BlockingGate(ast.NodeVisitor):
def __init__(self):
self.violations = []
def visit_Call(self, node):
if isinstance(node.func, ast.Name) and node.func.id in BANNED_CALLS:
self.violations.append(f"line {node.lineno}: banned call '{node.func.id}(...)'")
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
key = (node.func.value.id, node.func.attr)
if key in BANNED_MODULE_ATTRS:
if key[0] == "subprocess":
shell_true = any(
kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True
for kw in node.keywords
)
if shell_true:
self.violations.append(f"line {node.lineno}: '{key[0]}.{key[1]}(..., shell=True)'")
else:
self.violations.append(f"line {node.lineno}: banned call '{key[0]}.{key[1]}(...)'")
self.generic_visit(node)
def run_gate(source: str) -> dict:
tree = ast.parse(source)
gate = BlockingGate()
gate.visit(tree)
return {"blocked": len(gate.violations) > 0, "violations": gate.violations}
Running this against two candidate generations from a "custom pricing formula" feature:
insecure_snippet = '''
import os
def apply_user_formula(formula, x):
return eval(formula.replace("x", str(x)))
def cleanup_temp(path):
os.system(f"rm -rf {path}")
'''
safe_snippet = '''
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
def apply_user_formula(formula, x):
node = ast.parse(formula, mode="eval").body
return _eval_node(node, x)
def _eval_node(node, x):
if isinstance(node, ast.BinOp):
return _OPS[type(node.op)](_eval_node(node.left, x), _eval_node(node.right, x))
if isinstance(node, ast.Name) and node.id == "x":
return x
if isinstance(node, ast.Constant):
return node.value
raise ValueError("unsupported expression")
'''
print(run_gate(insecure_snippet))
print(run_gate(safe_snippet))
Actual output from running this:
{'blocked': True, 'violations': ["line 5: banned call 'eval(...)'", "line 8: banned call 'os.system(...)'"]}
{'blocked': False, 'violations': []}
Those two line numbers are worth tracing, because they are the first place node.lineno becomes checkable. The triple-quoted string opens with a newline, so its line 1 is blank and everything else is pushed down by one:
line 1: (blank, the newline right after the opening ''')
line 2: import os
line 3: (blank)
line 4: def apply_user_formula(formula, x):
line 5: return eval(formula.replace("x", str(x))) <- reported violation
line 6: (blank)
line 7: def cleanup_temp(path):
line 8: os.system(f"rm -rf {path}") <- reported violation
ast.parse numbers lines from 1 within the string it is handed, not within the enclosing file, so if this snippet is embedded in a larger file the gate has to add the snippet's own offset before the numbers are useful to a human reading a diff.
The insecure snippet is blocked automatically on two independent findings (eval on user-controlled input, and an unbounded os.system call built from an unsanitized path), with no reviewer needed to catch either. The safe snippet, which computes the same class of formula through a restricted AST-walking evaluator instead of eval, passes.
Trade-offs and pitfalls
- Sandbox isolation must be at the syscall level, not the language level. A Python
try/exceptaroundexec()does not stop network calls, filesystem writes, or resource exhaustion; use a container with a locked-down seccomp profile (an operating-system allow-list of permitted system calls) or a microVM (for example Firecracker, a lightweight virtual machine with its own kernel), with no network egress by default. - Coverage-guided fuzzing beats purely random fuzzing. Random fuzzing plateaus fast on any function with more than a couple of branches; a coverage-guided harness spends its budget on inputs that reach new code paths, which matters a lot when the fuzz budget per generation has to stay small enough to run in CI (continuous integration, the automated pipeline that builds and tests every proposed change).
- Over-blocking on style erodes trust in the gate. If the automatic blocking criteria include subjective judgments (naming conventions, code organization), authors start routing around the gate instead of respecting it. Keep the automatic blocking list to safety and correctness only; route everything subjective to the advisory, non-blocking review lane.
- Property-based tests need a contract to test against. If the generation request has no declared invariant, there is nothing for Hypothesis to check, and the golden-task suite becomes the load-bearing layer. Track what fraction of generation requests actually carry a checkable contract, and treat a low number as a product gap, not just a testing gap.
- A canary rollout needs an automatic rollback trigger, not a percentage alone. Shipping to 5% of traffic does nothing if nobody is watching; tie the canary stage to an automatic error-rate and complaint-rate threshold that rolls back on breach, the same discipline as the blocking criteria above, just applied post-deployment instead of pre-deployment.
Unlock Full Question Bank
Get access to all Debugging and Testing ML Systems interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.