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.
You scale synchronous distributed training from a small number of GPUs to many, or you enable mixed precision (AMP) and gradient accumulation, and training that was stable before now diverges or intermittently produces NaNs in the backward pass. Enumerate the plausible causes and propose a prioritized diagnostic plan: which single change you would test first, and what you would expect to observe if each cause were the real one.
Sample Answer
Direct answer. Scaling up GPU count or enabling mixed precision changes several things simultaneously (effective batch size, gradient synchronization behavior, numeric precision), so a training run that was stable before and diverges after needs a plan that isolates ONE variable at a time rather than reverting the whole change and guessing.
Three terms this depends on. A replica is one full copy of the model living on one GPU. DDP (DistributedDataParallel) is the synchronous data-parallel wrapper that runs one process per GPU and keeps the replicas in step. All-reduce is the collective operation that combines a tensor across every replica and hands every replica back the same combined result, which is how the per-replica gradients become one shared gradient each step.
Plausible causes, and what to test first. The single highest-prior cause when scaling GPU count is learning-rate scaling: if the effective batch size grows with GPU count but the learning rate doesn't, or is scaled incorrectly (linear scaling isn't always the right rule for every optimizer), training can become unstable purely from that mismatch, and it's the cheapest hypothesis to test. Worked with real numbers so "adjusted for the new batch size" is computable: a stable baseline of 8 GPUs at 32 examples per device is an effective batch of 256 at LR 0.1. Scaling to 64 GPUs at the same 32 per device gives an effective batch of 2048, an 8x increase. The linear rule says LR = 0.1 x 2048/256 = 0.8. The square-root rule says LR = 0.1 x sqrt(8) = 0.283. If the job was launched at 0.8 and diverges, the diagnostic rerun is at 0.283, and the sanity rerun (which trades throughput for certainty) is at the original 0.1.
Next, check batch-norm statistics: if BatchNorm computes per-replica statistics independently rather than synchronizing across replicas, a network with BatchNorm can behave differently, and less stably, at high replica counts purely because each replica now sees a smaller effective batch for normalization purposes. Then check all-reduce precision (is gradient synchronization happening in a lower precision than intended) and synchronization/seed-order differences (are all replicas actually initialized identically before training starts).
For mixed precision (AMP) specifically. Gradient-accumulation interactions with AMP are a common culprit. The mechanics, since the ordering is the whole bug: loss scaling multiplies the loss by a large factor S before the backward pass so that small fp16 gradients do not underflow to zero, and the gradients must later be divided by S ("unscaled") before the optimizer step. With gradient accumulation over K micro-batches, the correct order is accumulate-then-unscale: keep every micro-batch's gradients scaled, sum all K of them, and unscale ONCE immediately before the optimizer step. The wrong order, unscale-then-accumulate, divides each micro-batch's gradients by S as it goes and then accumulates already-unscaled values, while the scaler still believes it owes the division, so the effective gradient magnitude ends up off by a factor tied to K and the scaler's overflow check runs against tensors that are no longer on the scale it expects. Confirm which order your framework's AMP integration actually uses before assuming the bug is elsewhere. Separately, some DDP jobs show gradient divergence where SOME workers produce NaN and others don't purely from AMP's dynamic loss scaling adjusting independently per worker if it isn't explicitly synchronized, worth checking whether your framework synchronizes the loss-scale factor across replicas or lets each pick its own.
What you would expect to observe if each cause were the real one. This is the half that turns the list into a diagnosis:
- Learning-rate / batch-size mismatch: divergence arrives within the first few hundred steps and arrives EARLIER the more GPUs you add, and the step at which it blows up moves monotonically as you sweep LR. Rerunning the same 64-GPU job at 0.283 (or at 0.1) with everything else untouched restores stability. Gradient norms grow smoothly over several steps before the blow-up rather than jumping in one step.
- BatchNorm computing per-replica statistics: dump each replica's BatchNorm running mean and running variance buffers and compare them across ranks. Synchronized normalization keeps them near-identical; the unsynchronized failure shows them visibly diverging, with the spread widening as per-device batch size falls. Crucially, the gradients themselves stay finite the whole time, so this looks like instability without any numeric red flag. It gets worse as you hold total batch fixed and add GPUs (shrinking per-device batch), and it disappears when you swap in synchronized BatchNorm or raise the per-device batch back up.
- All-reduce running in a lower precision than intended: checksum or norm each rank's gradient tensor immediately BEFORE the collective and immediately AFTER it. Before, the ranks legitimately differ, since each saw a different data shard. After, every rank must hold a bit-identical tensor. The tell is that post-collective gradients agree only to the first three or four significant digits rather than exactly, and that the disagreement grows with rank count, because more partial sums mean more accumulated rounding. Compare against a reference reduction performed in fp32 to size the error.
- Seed or initialization-order mismatch: hash every replica's parameters at step 0, before any data is seen. They must be identical. If rank 0's hash differs from rank 1's at step 0, the replicas never started from the same weights, and the shared averaged gradient is being applied to divergent parameter sets. The signature is that divergence appears essentially at step 1, not after a hundred healthy steps.
- AMP unscale-then-accumulate ordering: instrument the global gradient norm immediately before the optimizer step, and compare against the same total batch run with K=1 (no accumulation). The two should agree within numerical noise. The ordering bug shows up as a clean multiplicative discrepancy tied to K, a factor of K or 1/K rather than a vague drift, which is what makes it identifiable rather than merely suspicious.
- AMP loss-scale desync across workers: log the current loss-scale value per rank per step. Healthy runs show every rank holding the same value and stepping it in lockstep. The failure shows ranks holding different scales at the same step (one at 1024 while another is at 32768), SOME ranks reporting NaN while others report clean gradients, and the identity of the NaN-reporting ranks changing from run to run.
Why the effective batch size is the thread running through all of this. Scaling GPU count in synchronous data-parallel training multiplies the effective batch size by the number of new replicas, which is exactly what makes the learning-rate hypothesis the highest-prior one and why the arithmetic above matters. It also means that if the instability appears only past a specific scale rather than at every scale, that threshold is itself evidence: a genuine LR/batch mismatch only becomes destabilizing once the step size and the curvature disagree badly enough, whereas a hardware or environment fault would be present regardless of how many replicas are running.
Prioritized order to test. Order by what each check actually costs to run, then by prior probability, because two of these are nearly free and one of them is a precondition for trusting any of the others.
- Free, so run these before spending a single GPU-hour on a rerun. Hash every replica's parameters at step 0: one hash per rank, zero training steps, and if the hashes disagree you are done, because none of the cross-rank comparisons below mean anything until the replicas provably start from the same weights. Under AMP, also log each rank's loss-scale value per step, which is one scalar per rank and settles the desync hypothesis outright. Neither of these is the most likely cause; they go first because they are cheaper than everything else and because they are the two that can invalidate the other measurements.
- Learning-rate scaling, the highest-prior cause and the first hypothesis worth real GPU-hours: rerun the 64-GPU job at 0.283, and at the original 0.1 if that is still unstable. One short run, one variable, directly falsifiable.
- AMP accumulate-then-unscale ordering, whenever gradient accumulation is on: compare the pre-optimizer global gradient norm against the same total batch run at K=1. One extra short run and one scalar, and the tell is a clean factor of K or 1/K rather than a drift.
- BatchNorm synchronization: dump the running-mean and running-variance buffers on every rank and diff them. More instrumentation than anything above, still far less than the last item.
- All-reduce precision, last: checksumming every rank's gradient tensor before and after the collective is the heaviest instrumentation in this list, and it is also the least common root cause for a symptom that appeared specifically at a scaling boundary.
List the common sources of nondeterministic training runs in frameworks like PyTorch or TensorFlow, and in libraries like NumPy, pandas, and scikit-learn on multi-core machines. Provide a prioritized checklist for debugging reproducibility issues, and describe what you would introduce to a legacy project that currently has none of this in place, to get the most impact for the least initial effort.
Sample Answer
Direct answer. Nondeterministic training runs almost always trace back to one of four places: an unseeded random-number generator somewhere in the stack (yours or a library's), a GPU operation whose parallel execution order isn't fixed run to run, data shuffling or sharding order that isn't pinned, or multi-threaded numeric libraries (BLAS/MKL) that sum floating-point values in a different order depending on thread scheduling. Any one of these is enough to make two runs with "the same" code and data disagree. Of the four, an unseeded generator is far and away the most common actual cause, because it only takes one library in a stack of five to be missed, and it is also the cheapest to eliminate, which is why it goes first. GPU operation order is the most common cause of the RESIDUAL difference that survives after you have seeded everything correctly, which is why it goes second. Thread counts come last because they only ever change the low-order bits, so they matter for a bit-for-bit requirement and rarely for anything else.
Prioritized checklist.
- Seed everything, not just one library. Python's
random, NumPy's generator, your framework's RNG (torch.manual_seedin PyTorch,tf.random.set_seedin TensorFlow), and (if applicable) CUDA's RNG each need an explicit seed; seeding onlynumpywhiletorchreseeds itself from system entropy is a common half-fix that looks like it worked because most of the pipeline is deterministic. - Check GPU-nondeterministic ops explicitly. Several common ops (certain forms of atomic-add-based scatter, some convolution algorithms) are nondeterministic by design for performance; frameworks expose a "deterministic mode" flag (
torch.use_deterministic_algorithms(True)in PyTorch,tf.config.experimental.enable_op_determinism()in TensorFlow, formerly theTF_DETERMINISTIC_OPS=1environment variable) that will either force determinism or raise an error naming the offending op, which is far faster than guessing. - Pin data order. Confirm your dataloader's shuffling is seeded and, for multi-worker loading, that worker-to-example assignment doesn't itself introduce nondeterminism (a subtlety that a naive seed-the-main-process-only setup misses). On the TensorFlow side this is
tf.data: pass aseedtoshufflealong withreshuffle_each_iteration=False, and be aware thatmapandinterleavewithnum_parallel_callsare allowed to emit elements OUT OF ORDER by default for throughput, so you needdeterministic=True(ortf.data.Options().deterministic = True) to get a fixed order back. - Limit or pin thread counts for CPU-side numeric libraries if you need bit-for-bit reproducibility on CPU paths (
OMP_NUM_THREADS,MKL_NUM_THREADS), since parallel reduction order changes the low bits of a floating-point sum.
scikit-learn, specifically. Two separate levers, and they fail differently. First, random_state: pass an explicit integer to EVERY estimator, splitter and sampler that accepts one, which includes the obvious cases (train_test_split, KFold(shuffle=True), RandomForestClassifier, KMeans, SGDClassifier, MLPClassifier, TSNE) and the easily-forgotten ones (GradientBoostingClassifier once subsample is below 1.0, PCA(svd_solver="randomized")). Leaving random_state=None does not mean "unseeded," it means "draw from the global NumPy legacy state," which is worse in a specific way: the result then depends on how many draws every other call in the process consumed first, so inserting one new call upstream silently changes every downstream result even though you seeded NumPy. Second, the thread knob, and it is worth being precise about WHICH one, because the obvious guess is wrong. n_jobs is not it: scikit-learn's joblib-parallel estimators pre-draw one seed per sub-estimator from random_state before dispatching, so RandomForestClassifier(random_state=0, n_jobs=1) and the same estimator at n_jobs=-1 fit bit-identical models, and setting n_jobs=1 buys you nothing in reproducibility while costing you all the parallelism. The knob that really does move the low bits is the OpenMP thread count (OMP_NUM_THREADS), which controls the parallel reductions inside the Cython/OpenMP routines that back KMeans, pairwise distances and the histogram gradient boosters; floating-point addition is not associative, so a different thread count sums the partials in a different order. The magnitude is what you would expect from reduction order, around 1e-15 on cluster centers, not a different model. So for a bit-for-bit requirement pin OMP_NUM_THREADS (checklist item 4), leave n_jobs alone, and otherwise set a tolerance rather than chasing the last bits.
pandas, specifically. pandas operations are individually deterministic; the nondeterminism enters through ROW ORDER, which those operations then inherit and bake in. Reading a partitioned dataset picks up whatever order the filesystem or glob returned the files in, so sort the file list explicitly. unique() and groupby(sort=False) return values in order of first appearance, so a different input order gives a different output order. drop_duplicates() keeps the first of a tied set, which is only well-defined once the input order is. And the one with real teeth for modeling is categorical encoding, where two similar-looking APIs behave differently and only one of them is order-sensitive. pd.factorize() assigns codes in order of first appearance, so the same three values arriving as b, a, c versus c, a, b produce opposite codes for b and c: that one is order-sensitive and it does silently change what the model is trained on while every summary statistic looks identical. astype("category") is safer than it is usually given credit for, because it SORTS the observed values before assigning codes, so row order alone cannot change the mapping. What it is still exposed to is the observed SET: build the vocabulary on a shard that never saw one category and the codes shift for everything after it (with a, b, c present, c is code 2; on a shard holding only a and c, c is code 1), so the same value reaches the model as a different integer. The rule that covers all of these is to sort by a unique key before any step whose output order feeds a computation, and to build category vocabularies from an explicit, complete, sorted list of the allowed values rather than from whatever the current partition happened to contain.
import os, random
import numpy as np
def set_all_seeds(seed: int):
random.seed(seed)
np.random.seed(seed) # legacy global RandomState
rng = np.random.default_rng(seed) # modern generator; pass it explicitly
try:
import torch
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.use_deterministic_algorithms(True) # raises on nondeterministic ops instead of silently using them
except ImportError:
pass
try:
import tensorflow as tf
tf.keras.utils.set_random_seed(seed) # wraps random.seed + np.random.seed + tf.random.set_seed
tf.config.experimental.enable_op_determinism()
except ImportError:
pass
return rng
One thing that does NOT belong in that function, and why. os.environ["PYTHONHASHSEED"] = str(seed) is a common line to find inside a set_all_seeds helper, and it does nothing for the process that runs it. Hash randomization is fixed when the interpreter starts, so setting the variable afterwards is read by nobody. Two pairs of runs show it, and you can run them yourself in four lines:
# assignment happens too late: two fresh interpreters disagree
$ python -c "import os; os.environ['PYTHONHASHSEED']='0'; print(hash('reproducibility'))"
$ python -c "import os; os.environ['PYTHONHASHSEED']='0'; print(hash('reproducibility'))"
-> two DIFFERENT integers
# exported before the interpreter starts: two fresh interpreters agree
$ PYTHONHASHSEED=0 python -c "print(hash('reproducibility'))"
$ PYTHONHASHSEED=0 python -c "print(hash('reproducibility'))"
-> the SAME integer both times
Do not compare the actual integer against anyone else's, because it depends on the interpreter build and platform. The only thing the test asks is whether the two runs in a pair agree with each other.
So the working form is to export it in the launcher (PYTHONHASHSEED=0 python train.py, or set it in the job spec / container entrypoint), not to assign it in Python. What it actually controls is the per-process randomization of hash() for strings and bytes, which changes SET iteration order, so it only matters when a set's iteration order feeds a computation: building a feature list or a category vocabulary from a set is the usual way this reaches a model. It has no effect on NumPy, PyTorch or TensorFlow generators.
Introducing this to a legacy project with none of it. Prioritize by cost-to-impact, not by completeness: (1) add seeding first, it's a one-file change and immediately narrows the debugging surface for every future bug; (2) add a lightweight experiment-tracking call (even just logging the seed, git commit hash, and key hyperparameters to a file per run) before anything else, since without it you can't even tell later which run's code and data actually produced a given result; (3) dataset and code versioning (a content hash or DVC-style pointer for the training data, alongside the existing code version control) comes third, it matters most once you're trying to reproduce a run from weeks ago, which is a real but less immediate pain than "I can't reproduce yesterday's run." Full bit-for-bit determinism across different hardware is usually not worth chasing for a legacy project; getting the same result on the same hardware, with a known seed and known data version, delivers most of the debugging value for a fraction of the effort.
A new model version passed all pre-deployment tests but shows notable degradation once in production. Walk through a forensic post-deployment incident-response plan: what evidence you would gather (logs, feature snapshots, model versions), how you would compare pre- and post-deployment input distributions and schemas, how you would check for a data-pipeline or feature-transformation change that the pre-deployment tests didn't cover, and what you would change about your test suite so a similar regression is caught next time.
Sample Answer
Direct answer
A model that passed every pre-deployment test and still regressed in production tells you something specific: the tests were measuring the wrong thing, or measuring the right thing on the wrong data. My first move is forensic, not corrective: freeze and gather evidence before anything about the environment changes further, compare the world the tests ran against to the world production actually sees, and only then propose a fix. The closing, and in my view most important, part of the response is diagnosing exactly which gap in the test suite let this through, because fixing this one regression without closing that gap just means the next one repeats it.
Structured elaboration
Evidence to gather, before it rotates out of retention. Pull the exact model artifact and its version identifier that was deployed (not "the version that was supposed to be deployed"; deployment mismatches are themselves a real cause), the serving configuration active at the time, and a sample of real inference logs from the incident window (inputs as hashes or fingerprints if raw payloads cannot be retained, plus outputs, confidence scores, and latencies). Separately capture a feature-store snapshot for the incident window, since features can be point-in-time and the values a request saw when it happened may not match what you would compute by re-querying now. Note the exact deployment timestamp and cross-reference it against any other change that landed around the same time (a feature-store schema change, an upstream data-source migration, a dependency bump), since "new model version" and "new bug" being simultaneous does not prove the model version caused it.
Compare pre- and post-deployment input distributions and schemas. For every feature the model consumes, compare the distribution in the incident window's production traffic against both the training distribution and the pre-deployment test distribution, using a two-sample test such as Kolmogorov-Smirnov (KS) for continuous features or a chi-square test for categorical ones. Separately, and more cheaply, diff the schema itself: field names, types, nullability, and cardinality, since a schema drift (a field silently becoming nullable, a categorical feature gaining new values) is a harder, more binary signal than a distributional shift and is usually faster to confirm or rule out.
Check for a data-pipeline or feature-transformation change the pre-deployment tests did not cover. This is the step that specifically explains "passed all tests and still failed": walk the feature-transformation code path end to end and ask, for each step, whether the pre-deployment test suite exercised it against data shaped like what actually reached production. Common gaps: the test suite used a static, curated golden dataset (a fixed, checked-in set of example inputs with their expected outputs, captured once and used as the pre-deploy reference ever since) that no longer represents the live feature distribution; the test suite validated the model in isolation but not the full serving path, so a change in an upstream feature-extraction service between test and deploy went unchecked; or the test suite covered the model's own code but a shared preprocessing library was bumped independently and neither the model tests nor a compatibility test caught the interaction. Confirm by re-running the pre-deployment test suite's own inputs through the CURRENT (post-deployment) feature pipeline and diffing the outputs against what the test suite recorded at deploy time; a difference here directly proves the pipeline moved out from under a test that never re-checked itself against fresh production-shaped data.
What to change about the test suite so this class of regression is caught next time. Once the specific gap is identified, the fix is almost always one of three shapes: replace a static golden dataset with a rolling, periodically-refreshed sample of real (or synthetic but representative) recent production data, so the test data cannot silently drift out of sync with what production actually sees; extend the pre-deployment suite from model-only testing to full serving-path integration testing, so a change anywhere in the request's actual path is exercised, not just the model's own forward pass; and add a schema and distribution CONTRACT check as a hard pre-deploy gate (not just a test that can be skipped under time pressure), so any feature schema or distribution change beyond a defined tolerance blocks the deploy automatically rather than depending on a human noticing. Define that tolerance in numbers or it will not survive its first deadline: block the deploy if any feature's Kolmogorov-Smirnov statistic against the last 7 days of production exceeds 0.1, if its null rate moves by more than 2 percentage points, or if its median moves by more than 20 percent. A 0.1 gate is deliberately loose relative to sampling noise: two clean samples of the sizes below land around 0.01 to 0.02, so 0.1 blocks real movement without firing on ordinary variation.
Worked example
Concretely: precision at the deployed threshold fell from 71 percent to 63 percent over the first two days after the release, an 8-point drop. The post-deployment schema diff shows nothing (all fields present, all types unchanged), but the KS test on one input feature, "days since last activity," shows a clear shift between the pre-deployment test distribution and the incident-window production distribution. The numbers: the golden set holds 5,000 rows with a median of 14 days since last activity; the incident window holds 20,000 production rows with a median of 31 days; the two-sample KS statistic between them is 0.294 with a p-value below 10−300. For scale, two clean samples drawn from the same distribution at those sizes give a KS statistic of about 0.013, with 0.020 at the 95th percentile, so 0.294 is not a borderline call, it is roughly fifteen times the noise floor. Re-running the pre-deployment test suite's own golden inputs through the current feature pipeline reproduces the ORIGINAL test-time values exactly (the pipeline code did not change), which rules out a pipeline bug. The remaining explanation, confirmed by checking the golden dataset's last-refreshed timestamp, is that the golden dataset itself was captured eight months earlier and the live user population's activity pattern had genuinely moved since then; the pre-deployment tests kept passing because they were comparing the new model against stale data, not because the new model actually generalized to current production inputs. Why this version and not the earlier ones that tested against the same stale golden set: the new version was tuned considerably harder against that distribution (more capacity and a longer hyperparameter search, selected on golden-set performance), so it fit the eight-month-old activity pattern more tightly and had correspondingly more to lose when production stopped matching it. The earlier, less tightly-fit versions were leaving performance on the table against the golden set, which is exactly what made them less sensitive to the golden set being wrong. The test-suite fix that follows directly from this evidence is the rolling-refresh golden dataset described above, specifically for this case, not a full serving-path integration test, since the pipeline code itself was proven unchanged.
Trade-offs and pitfalls
The most common wrong turn is rolling back and closing the incident once metrics recover, without completing the forensic comparison. A rollback restores service; it does not tell you whether the next model version will hit the exact same gap, and if the golden dataset is stale, every future release trained and tested the same way is at risk until that is fixed.
A second pitfall is over-correcting the test suite into something too expensive to run on every deploy. A full production-scale, fully-refreshed integration suite on every commit can become slow enough that teams start skipping it under deadline pressure, recreating the exact failure mode you were trying to close. Split the strategy by cost: fast, deterministic schema and distribution CONTRACT checks as a hard, cheap gate on every deploy, and a periodic (not necessarily per-deploy) refresh of the golden dataset and full-path integration run, so the properties that change slowly are not re-verified at a cost that does not match how often they actually move.
A third pitfall is confirming only ONE candidate cause and stopping. In this example, the schema diff, pipeline replay, and distribution test were run as a set specifically because any one check alone (say, the KS test showing drift) would not have distinguished "the golden dataset is stale" from "the pipeline itself changed and happens to now match old test values by coincidence." Running the full battery, and confirming the pipeline replay reproduced the ORIGINAL recorded values, is what turned a plausible hypothesis into a confirmed root cause.
A production model's performance drops sharply right after a change to the upstream data-ingestion pipeline. Outline a systematic debugging approach: validating raw inputs, comparing feature distributions before and after the pipeline change, verifying schema and null-handling behavior, replaying historical data through the new pipeline to check for silent differences, and using a shadow deployment to isolate whether the regression is in the data or the model. Describe the preventative tests you would add so a future pipeline change can't cause the same regression silently.
Sample Answer
Direct answer
Work from cheapest to most expensive: validate the raw inputs first, then compare feature distributions and verify schema and null-handling on the same data, then replay historical data through the new pipeline to see whether the pipeline code itself behaves differently on identical inputs, and only then reach for a shadow deployment to separate a data-side cause from a model-side one, since a shadow deployment is the most expensive diagnostic and the earlier steps usually already answer the question. The single most common failure this sequence protects against is treating an aggregate, whole-population distribution check as sufficient when the real regression is concentrated in one segment the aggregate view dilutes into invisibility.
Structured elaboration
Validating raw inputs. Before touching any statistics, confirm the pipeline change did not simply break ingestion: row counts per upstream source in the expected range, required fields present, types conforming to the expected schema. This is the cheapest check and catches gross breakage (a source that silently stopped sending a field, a partial ingestion failure) before spending effort on subtler distributional analysis that a gross failure would make meaningless anyway.
Comparing feature distributions before and after the pipeline change. For each feature, compare its distribution from before the change against its distribution from after, using a distribution-free test such as the two-sample Kolmogorov-Smirnov (KS) test, appropriate here because feature distributions are typically continuous and not reliably normal, so a test that does not assume a particular shape is the safer default. That test returns two numbers and the comparison below turns on telling them apart. The KS statistic is the single largest vertical gap between the two samples' cumulative distribution curves (for each value on the horizontal axis, the curve gives the share of that sample falling at or below it), so it runs from 0 when the two distributions are identical to 1 when they do not overlap at all, and it reads directly as "at their widest disagreement, these two samples differ by this much accumulated share." The p-value says only how unlikely a gap that large would be if both samples really did come from the same distribution; it says nothing about how large the gap is. Those come apart at scale: with enough rows, a gap far too small to matter still produces a tiny p-value, so the statistic is the effect size and has to be read alongside the p-value rather than replaced by it. Critically, do this segmented by whatever cohort dimensions are available (region, customer type, input source), not only on the whole population: a change concentrated in one segment can be small enough relative to the whole population that an aggregate-only comparison misses it, while the same comparison restricted to the affected segment shows it clearly.
Verifying schema and null-handling behavior. Confirm explicitly, not by inference, that types, allowed value ranges, and the specific handling of missing values match the contract the current model was trained against. A silent change in null-handling, for example a field that used to arrive as an explicit null and now silently gets coerced to zero by an upstream default, produces a systematic, hard-to-spot shift that a generic distribution comparison can sometimes miss if the coerced value happens to fall within an otherwise plausible range.
Replaying historical data through the new pipeline. Take a fixed batch of raw data from before the change, whose resulting features were already recorded by the old pipeline, and run that identical raw batch through the new pipeline code. Diff the newly computed features against the originally recorded ones for the exact same input rows. This is the cleanest possible causal test available: because the raw input is literally identical, any difference in the output features can only come from the pipeline code itself, not from the real world having changed, which is exactly the distinction needed to separate "the code changed behavior" from "the underlying data genuinely shifted."
Using a shadow deployment to isolate data versus model. Once the cheaper checks above have narrowed things down, run the current production model against the new pipeline's live features in shadow mode, scored but never served to users, and compare its offline performance on a labeled sample of that shadow traffic against its known historical performance. If the same, unchanged model degrades when fed the new pipeline's features, the fault sits in the features, not the model. If a retrain is also under consideration, comparing the old model and a newly retrained model against the identical new feature set is what isolates whether any remaining gap is model-side rather than data-side.
Preventative tests for the future. Turn the historical-replay diff from an ad hoc investigation step into an automated regression test: a fixed historical batch with its expected feature output checked into the test suite, run automatically whenever the pipeline code changes, so a future silent behavior change fails a test instead of reaching production. Add an explicit schema and null-handling contract test that asserts the exact type, range, and null-treatment behavior the model depends on. Add a scheduled, segmented distribution-drift monitor comparing live feature distributions against the training-time reference on an ongoing basis, not only around known pipeline changes, since not every silent regression will coincide with a deploy someone remembers to check against.
Worked example
Comparing an order_value feature before and after a pipeline change that silently broke currency normalization for international orders only, on synthetic data (4,000 rows before, 4,000 after, about 25% flagged international in each), first at the whole-population level, then segmented. The seed is pinned so every number below is reproducible rather than a one-off draw:
import numpy as np
from scipy import stats
N, INTL_SHARE, BUG_FACTOR = 4000, 0.25, 1.35
MU, SIGMA = np.log(40.36) - 0.125, 0.5 # order_value is lognormal, mean about 40
rng = np.random.default_rng(5268) # pinned, so this table reproduces
before = rng.lognormal(MU, SIGMA, N)
before_intl = rng.random(N) < INTL_SHARE
after = rng.lognormal(MU, SIGMA, N)
after_intl = rng.random(N) < INTL_SHARE
# The bug: currency normalization silently mis-scales international orders only.
after = np.where(after_intl, after * BUG_FACTOR, after)
comparisons = [
("whole population", before, after),
("domestic segment only", before[~before_intl], after[~after_intl]),
("international segment only", before[before_intl], after[after_intl]),
]
print(f"{'comparison':<28}{'n before':>9}{'n after':>9}{'KS stat':>10}{'p-value':>12}")
for label, a, b in comparisons:
res = stats.ks_2samp(a, b)
print(f"{label:<28}{len(a):>9}{len(b):>9}{res.statistic:>10.4f}{res.pvalue:>12.2g}")
m_before, m_after = before[before_intl].mean(), after[after_intl].mean()
print(f"\ninternational mean order_value: {m_before:.2f} -> {m_after:.2f} "
f"(ratio {m_after/m_before:.4f}, bug applied {BUG_FACTOR})")
Output:
comparison n before n after KS stat p-value
whole population 4000 4000 0.0610 6.8e-07
domestic segment only 2994 3000 0.0188 0.65
international segment only 1006 1000 0.2441 1e-26
international mean order_value: 40.08 -> 54.11 (ratio 1.3499, bug applied 1.35)
| comparison | KS statistic | p-value |
|---|---|---|
| whole population | 0.0610 | 6.8e-07 |
| domestic segment only | 0.0188 | 0.65 |
| international segment only | 0.2441 | 1e-26 |
The whole-population test does detect something (p=6.8e-07), but its KS statistic of 0.061 looks like a mild, easy-to-dismiss shift. Read literally, 0.061 says that at the point where the before and after cumulative curves are furthest apart they differ by about 6 percentage points of accumulated mass, which on a 0-to-1 scale is close to the identical end. With 4,000 rows on each side, even a gap that small is comfortably significant, and that combination, a tiny statistic with a convincing p-value, is exactly the trap: read the p-value alone and it looks like a confirmed problem, read the statistic alone and it looks like nothing, and neither reading tells you where to go next. Segmenting shows what actually happened: the domestic segment shows no significant difference at all (p=0.65, indistinguishable from noise), while the international segment alone shows a far larger and far more significant shift (KS statistic 0.244, p effectively zero). On the same 0-to-1 scale, 0.244 means those two curves separate by over 24 percentage points of accumulated mass at their widest, exactly four times the whole-population gap, and it does that on only about a quarter of the rows, which is precisely why averaging it in with the unaffected three quarters shrank it to 0.061. The international segment's mean order value moved from 40.08 to 54.11, a ratio of 1.3499, recovering almost exactly the 1.35x mis-scaling the underlying bug actually applied. An investigation that stopped at the whole-population number would have seen a modest, ambiguous signal; segmenting turned it into an unambiguous, localized, and nearly root-cause-identifying result.
Trade-offs and pitfalls
- Reaching for the shadow deployment before the cheaper checks wastes the most expensive tool on a question the earlier steps usually already answer. Sequencing this from cheapest to most expensive is not just tidiness, it avoids spending shadow-deployment effort re-discovering what a distribution comparison would have shown directly.
- An aggregate-only distribution comparison can genuinely miss a real, severe, segment-concentrated regression, exactly as the worked example shows; always segment by every cohort dimension available before concluding a feature is unaffected.
- The historical-replay diff is the step most often skipped, and it is the one that actually distinguishes a code bug from a genuine real-world shift. Without it, a team can spend real effort investigating "why did the world change" when the honest answer is "the pipeline code changed and the world did not."
- A replay test only covers the inputs it was built from. It will not catch a bug that only manifests on an input pattern the historical batch never contained, so it complements, rather than replaces, the ongoing distribution-drift monitor.
Production predictions for identical inputs sometimes differ between requests. List the plausible root causes at the model, runtime, and infrastructure levels. For each, describe a concrete test or configuration change that would make inference deterministic, and explain the performance trade-off you would be accepting.
Sample Answer
Direct answer. Identical inputs producing different predictions between requests traces to one of three layers, the model's own computation, the serving runtime, or the surrounding infrastructure, and each has a distinct fix with a distinct performance cost, so the first job is figuring out which layer, not jumping straight to a fix.
Model-level causes. An unseeded random-number generator inside the model itself (dropout not properly disabled at inference, a sampling step in a generative model) will produce different outputs by design; the test here is simple: run the same input through the model twice in the exact same process and see if outputs differ, and read the SIZE of the difference, not just its existence. Model-level randomness (live dropout, an active sampling step) moves the output visibly, often enough to change the argmax. Runtime kernel nondeterminism moves only the last few significant digits and never the decision. So a large difference points at this layer, a last-bits difference points at the runtime layer below, and bit-identical output on a CPU path rules out both. Fix: ensure eval mode is set, and if intentional sampling is part of the design, make the seed for that sampling step an explicit, loggable input rather than left to system entropy, trading a small amount of implementation complexity for reproducibility on demand.
Runtime-level causes. Nondeterministic GPU operations (certain scatter/reduction kernels chosen for performance over determinism) can make bit-identical inputs produce slightly different floating-point results run to run. Fix: use the framework's deterministic-algorithms mode; the trade-off is a measurable performance cost, since the deterministic kernel variants are often slower than the default, non-deterministic ones, worth paying for a use case where reproducibility genuinely matters (compliance, debugging) and not worth paying for one where it doesn't (a recommendation ranking where sub-percent output jitter has no real consequence).
Infrastructure-level causes. An asynchronous feature fetch with different side effects (a race between two upstream feature sources resolving in different order under different load) can make the SAME logical request see different feature values depending on timing, which is a data problem masquerading as a model nondeterminism problem. Fix: make the feature-fetch step for a single request deterministic (all fetched features version-tagged to a single request-time snapshot), at the cost of some added latency or complexity in the feature-fetch path. A hot-patched model version, where different requests are served by different, not-yet-fully-rolled-out model artifacts during a deployment, can look exactly like model-level nondeterminism from the outside; the fix (verifying the artifact version alongside every logged prediction, so this is distinguishable from true nondeterminism after the fact) costs only a small amount of additional logging, not runtime performance.
The performance trade-off, sized by WHERE the cost lands. The useful distinction is not cheap versus expensive, and it is not one-off versus recurring either, since both the infrastructure fix and the runtime fix are paid on every request for the life of the deployment. The distinction that actually predicts the bill is whether the cost is a FIXED adder per request or a cost levied on every unit of compute, because only the second one grows with your model and your batch size:
- Model-level (eval mode, explicit sampling seed): no runtime cost at all, and slightly negative if anything, since disabling dropout at inference removes work rather than adding it. Pure implementation hygiene.
- Artifact-version logging: a few extra bytes per logged prediction and no change to the request path. Effectively free.
- Infrastructure-level (single request-time feature snapshot): a genuine latency add on every request, but a FIXED one that lands once in the fetch path, not on every unit of compute, so it does not grow when the model does. It frequently nets out near zero because collapsing several ad-hoc reads into one snapshot read removes round trips as well as adding one. It is small relative to a model whose own compute is tens of milliseconds, and it is NOT small if the snapshot forces a slower storage path than the reads it replaced, which is the case to actually measure before shipping.
- Runtime-level (deterministic kernels): the only one that charges compute on every single request for the life of the deployment. Its size depends entirely on what fraction of your model's hot path sits in operations whose default kernel is nondeterministic: if none of them do, the flag costs nothing; if a nondeterministic scatter or reduction is in the inner loop, it can be substantial. There is no percentage worth quoting here, because the honest number comes from one micro-benchmark, running the same batch with the flag on and off and taking the ratio, which takes minutes and beats any published figure for your model.
So the earlier statement stands with its qualifier attached: the model-level and logging fixes are genuinely free, the infrastructure fix is a fixed per-request latency add in the fetch path that you should measure but will usually accept, and the runtime fix is the one whose cost scales with the model's own compute, which is why it's worth reaching for deliberately for the specific requests that need it rather than turning it on globally by default.
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.