Code Review and Working with Existing Codebases Questions
Reviewing others' code and navigating unfamiliar systems: giving and receiving actionable review feedback, spotting correctness and design issues, and reading and understanding large or legacy codebases before changing them. Covers collaborative coding norms, incremental change in shared repositories, and verifying changes against existing behavior. The team-facing side of day-to-day engineering.
Given this pandas snippet, walk through each line as you would to a junior data scientist and explain inputs, outputs, and intent:
df = df.drop_duplicates(subset=['user_id', 'event_date'])
df['event_date'] = pd.to_datetime(df['event_date'])
df = df[df['event_date'] >= '2023-01-01']
Explain potential pitfalls and one unit test to add.
Sample Answer
Direct answer
Each line does one job on the way to a clean, deduplicated, recent-events dataset: drop duplicate user-and-date rows, convert the date column from text to a real datetime type so it can be compared correctly, then filter down to events from 2023 onward. Walking a colleague through it means naming each line's input, output, and assumption, not just what the syntax does.
Structured elaboration
Line by line
df = df.drop_duplicates(subset=['user_id', 'event_date']). Input: the full dataframe (pandas' table-like data structure). Output: a dataframe with one row per unique(user_id, event_date)pair, keeping the first occurrence by default. Intent: treat repeated rows for the same user on the same date as duplicates, for example from a retried API call. Worth naming to a junior colleague: "first occurrence" is arbitrary unless the rows were sorted meaningfully first; if which duplicate survives matters, sort before deduplicating.df['event_date'] = pd.to_datetime(df['event_date']). Input: theevent_datecolumn as text. Output: the same column as an actual datetime type. Intent: text can't be compared or filtered as a date correctly for every format; converting first makes the next line's comparison mean what it looks like it means.df = df[df['event_date'] >= '2023-01-01']. Input: the dataframe with a real datetime column. Output: only rows on or after January 1, 2023. Intent: restrict analysis to a defined recent window; because the column is now a real datetime type, pandas correctly parses the string on the right for the comparison, rather than doing a plain text comparison.
Pitfalls
to_datetimeon an inconsistent or malformed date string raises an error by default rather than silently producing a wrong date, which is safer than it sounds but means the pipeline breaks hard on one bad row unless you explicitly decide how to handle it, for example converting unparseable values into a missing-date marker and then deciding whether to drop or flag them.- Deduplicating before the date conversion means "duplicate" is judged on the raw string form of the date. Two rows representing the same date but formatted differently would NOT be caught as duplicates, since the strings differ. Converting to datetime before deduplicating avoids that trap.
- The final filter creates a view that's a slice of the original; further chained assignment on it can trigger pandas' copy-versus-view warning if not handled with an explicit copy, worth flagging to a junior colleague since it's a very common pandas gotcha.
Worked example
I ran this exact three-line snippet against a small sample dataset to confirm the behavior:
import pandas as pd
df = pd.DataFrame({
'user_id': [101, 101, 102, 103, 103, 103],
'event_date': ['2022-11-05', '2022-11-05', '2023-01-15', '2023-02-01', '2023-02-01', '2023-03-10'],
'event_type': ['click', 'click', 'purchase', 'click', 'click', 'purchase'],
})
print("rows before:", len(df))
df = df.drop_duplicates(subset=['user_id', 'event_date'])
print("rows after dedup:", len(df))
df['event_date'] = pd.to_datetime(df['event_date'])
print("dtype after conversion:", df['event_date'].dtype)
df = df[df['event_date'] >= '2023-01-01']
print("rows after date filter:", len(df))
print(df)
Actual output:
rows before: 6
rows after dedup: 4
dtype after conversion: datetime64[us]
rows after date filter: 3
user_id event_date event_type
2 102 2023-01-15 purchase
3 103 2023-02-01 click
5 103 2023-03-10 purchase
Six rows go to four after deduplication (the two identical 101, 2022-11-05 rows collapse to one), then to three after the date filter drops the pre-2023 row. I separately confirmed the pitfall above is real: feeding pd.to_datetime a list containing 'not_a_date' raises a ValueError naming the exact string that failed to parse, rather than silently producing a wrong date; passing errors='coerce' instead turns that value into a missing-date marker (one row) rather than raising.
One unit test to add
def test_dedup_and_filter_drops_pre_2023_and_duplicates():
df = pd.DataFrame({
'user_id': [1, 1, 2],
'event_date': ['2022-12-31', '2022-12-31', '2023-01-01'],
})
df = df.drop_duplicates(subset=['user_id', 'event_date'])
df['event_date'] = pd.to_datetime(df['event_date'])
result = df[df['event_date'] >= '2023-01-01']
assert len(result) == 1
assert result['user_id'].iloc[0] == 2
This pins both behaviors together (the duplicate collapsed, the pre-2023 row excluded) against a known, hand-checkable expected result, so a future change to either line that breaks either behavior fails loudly instead of silently changing downstream numbers.
Trade-offs and pitfalls
- Silently dropping unparseable dates trades a hard failure for a soft one; only do this if you also log or count how many rows were affected, otherwise you lose visibility into a real data quality problem
- Deduplicating before type conversion is a subtle trap because it looks correct and usually is correct on clean data; it only breaks on inconsistently formatted date strings, exactly the kind of bug that survives review and shows up later as a mysterious discrepancy
- Comparing against a plain string relies on pandas correctly parsing it during the comparison; an explicit timestamp object is marginally more robust and easier for a junior engineer to trust at a glance
You are reviewing a pull request that normalizes features with a manual loop. The diff shows a Python function that scales each column in a loop instead of vectorized operations. Provide constructive review comments focusing on performance, readability, edge cases, and test suggestions. Include a concrete code suggestion or alternative approach.
Sample Answer
Direct answer
The core review comment is that a Python-level per-row loop over a table is both slower and more error-prone than a vectorized operation (one that operates on a whole column at once instead of cell by cell), and this specific diff also has a real correctness bug: dividing by a zero-span constant column, plus a dtype issue I reproduced directly. I'd ask for the loop to be replaced with a vectorized min-max scale and a test covering the constant-column edge case.
Structured elaboration
Performance
Iterating with row-by-row indexing inside a Python for loop touches the table one cell at a time through the library's indexing machinery on every iteration, instead of letting the underlying vectorized engine operate on the whole column as a single operation. The fix is a vectorized subtract-and-divide across the columns at once, computed as one operation on the underlying array rather than one Python-level operation per row.
Readability
The loop version buries the actual formula (min-max scaling) inside indexing boilerplate; a reviewer has to mentally simulate the loop to recover what the code is doing. The vectorized version reads as the formula itself, which is what a reviewer, and a future maintainer, actually wants to verify is correct.
Edge cases
- Constant column (max equals min): the formula divides by zero. I verified this concretely: an unguarded vectorized version on a constant column produces a missing-value marker for every entry rather than raising, which is silent and easy to miss. This needs an explicit decision (skip the column, fill with a default, or raise a clear error) rather than an accidental one.
- Integer-typed columns: I actually ran the loop version against integer-typed columns and it raised a type error, because assigning a fractional result back into an integer column cell by cell isn't allowed. This is a real, reproducible bug in the diff as written, not a hypothetical: any caller passing an integer-typed feature column hits it immediately. The vectorized version needs the same fix: cast the columns to a floating-point type before scaling.
- Missing values already present: neither version currently guards against a missing value in the input propagating through the min, max, and arithmetic; worth a comment asking whether that's intended.
Concrete suggestion
import numpy as np
def normalize_features(df, columns):
df = df.copy()
df[columns] = df[columns].astype(float)
col_min = df[columns].min()
col_max = df[columns].max()
span = (col_max - col_min).replace(0, np.nan) # avoid divide-by-zero; NaN signals "cannot scale a constant column"
df[columns] = (df[columns] - col_min) / span
return df
Test suggestions
- A normal case with known min/max per column, asserting the scaled values match hand-computed expected values
- A constant column, asserting it comes out as a missing-value marker (or whatever the team agrees on) rather than raising or silently returning garbage
- An integer-typed input column, asserting it doesn't raise, which would have caught the dtype bug above
Worked example
I ran both the original loop version and the suggested vectorized version against the same float-typed sample data to confirm they produce identical results, and separately confirmed the two edge cases above.
import pandas as pd
import numpy as np
def normalize_features_loop(df, columns):
df = df.copy()
for col in columns:
col_min = df[col].min()
col_max = df[col].max()
for i in range(len(df)):
val = df.iloc[i][col]
df.iloc[i, df.columns.get_loc(col)] = (val - col_min) / (col_max - col_min)
return df
def normalize_features_vectorized(df, columns):
df = df.copy()
col_min = df[columns].min()
col_max = df[columns].max()
span = (col_max - col_min).replace(0, np.nan)
df[columns] = (df[columns] - col_min) / span
return df
df = pd.DataFrame({
'feature_a': [10, 20, 30, 40],
'feature_c': [1, 2, 3, 100],
}).astype(float)
loop_result = normalize_features_loop(df, ['feature_a', 'feature_c'])
vec_result = normalize_features_vectorized(df, ['feature_a', 'feature_c'])
print(np.allclose(loop_result[['feature_a', 'feature_c']], vec_result[['feature_a', 'feature_c']]))
print(vec_result)
Actual output:
True
feature_a feature_c
0 0.000000 0.000000
1 0.333333 0.010101
2 0.666667 0.020202
3 1.000000 1.000000
Both versions agree exactly on this data. I also confirmed the dtype pitfall is real: calling the loop version on the original integer-typed columns, without the cast to float first, raises a type error naming the specific fractional value it couldn't place into an integer column, on the very first row where the scaled value isn't a whole number.
Trade-offs and pitfalls
- A vectorized fix that skips the dtype cast just moves the same bug from "raises inside a loop" to "raises, or silently truncates depending on the library version, on the whole-column assignment"; the fix has to address the dtype, not only the loop
- Silently turning a constant column into a missing-value marker is defensible but must be a decision the team agrees on, since a downstream model silently receiving missing features can fail in a much more confusing way, far from this code
- Vectorization sometimes trades a small amount of readability for a large amount of speed on genuinely large data, but here the vectorized version is also more readable, so there's no real trade-off to accept in this specific case
Describe your approach to code reviews in ML repositories where changes can affect reproducibility, experiment logs, and model artifacts. How do you structure a review checklist to catch issues that could cause silent model drift or irreproducible experiments?
Sample Answer
In an ML repository, "the tests pass" is not enough evidence that a change is safe, because a change can be functionally correct and still quietly alter what gets trained, how it is measured, or whether the run can be reproduced later. I treat an ML code review as answering three questions in order: does this change touch something that can silently shift results (data, randomness, or configuration), is there a fixed checklist item for that surface, and can I verify the fix with an automated check rather than trusting the diff by eye.
What the checklist covers
- Configuration: every training-affecting parameter lives in a versioned config file (not a hardcoded constant or an environment variable that differs by machine), and any change to a default bumps the config's version.
- Randomness: the random seed is explicitly set and propagated to every library that uses one (NumPy, PyTorch, TensorFlow), and the PR states whether determinism is expected end to end or only approximately, since some GPU operations stay non-deterministic even with a fixed seed.
- Data handling: any change to preprocessing, filtering, or how a dataset is split gets reviewed specifically for leakage (validation examples ending up in training) and for whether it silently changes the composition of an existing split, not just its size.
- Metrics and logs: metric names, units, and log schemas stay backward compatible, or the PR includes a migration note so historical experiment logs remain comparable to new ones.
- Model artifacts: every saved artifact is tagged with the training commit hash, dataset hash, hyperparameters, and framework versions, so a reviewer (or a future you) can tell exactly what produced it.
Worked example
Say a PR changes a training pipeline's split ratio from 80/20 to 75/25 on a 20,000-row dataset, but does not touch the random seed. Under the old split, training got 16,000 rows and validation got 4,000. Under the new split, training gets 15,000 and validation gets 5,000, a 1,000-row shift. The unchanged seed is misleading here: it controls which rows get shuffled, not where the split boundary falls, so the validation set's actual contents change even though nothing about the model architecture did. A reviewer following the checklist catches this because "changes a split" is a flagged data-handling item, and would ask the author to confirm whether the new validation metrics are still comparable to the last logged baseline, since a silently regenerated validation set can look like a model regression when it is really just a measurement change.
Trade-offs and pitfalls
Applying the full checklist to every PR, including ones that only touch a dashboard or a docstring, slows review velocity for no safety benefit, so scope it to PRs that touch the training or evaluation path and keep peripheral changes lightweight. A separate trap is capturing metadata (commit hash, dataset hash) without ever checking it: logging a hash is not the same as a continuous integration (CI) job that re-verifies the hash matches before allowing a merge. Finally, resist trusting a large notebook diff at face value, since notebook outputs and execution order hide state that a linear code diff will not surface; require a script-equivalent or a stripped notebook diff for anything that actually trains a model.
Design a peer-review rubric for ETL or transformation pull requests that balances velocity and reliability. List 6-8 rubric items (for example, tests, readability, performance considerations) and explain how each item should be scored and enforced in the review process.
Sample Answer
A peer-review rubric for extract, transform, load (ETL) or transformation pull requests has to score two different things at once: will this data be reliable, and how much reviewer time will this actually cost. I use seven items on a 0-2 scale (0 = missing, 1 = partial, 2 = complete), split between reliability items that gate the merge and delivery-health items that stay visible but do not block on their own. Whatever can be checked by a machine is enforced in continuous integration (CI); whatever needs judgment goes to a human reviewer.
The seven items
- Tests: 0 = none, 1 = unit tests only, 2 = unit plus an integration run against sample data. CI-enforced; unit tests block the merge, integration tests are required for anything touching a production table.
- Data-quality assertions: 0 = none, 1 = basic null/uniqueness checks, 2 = ranges, referential integrity (a foreign-key-style reference still points to a row that actually exists), and anomaly checks. CI-enforced; a failing assertion must be fixed or explicitly justified with a mitigation plan in the PR description.
- Schema and contract changes: 0 = undocumented or breaking with no migration, 1 = documented with no migration plan, 2 = documented with a migration or backfill plan and backward compatibility. Reviewer-enforced; a breaking change needs a second approver.
- Readability and maintainability: 0 = hard to follow, 1 = readable but undocumented, 2 = clear, modular, with docstrings. Style is linted in CI; architectural clarity is a human judgment call.
- Performance and resource cost: 0 = untested and likely expensive, 1 = a rough estimate, 2 = profiled with cost or latency numbers included. Reviewer-enforced for anything touching a heavy job.
- Observability: 0 = no metrics or logs, 1 = basic logs, 2 = row-count and latency metrics with an updated dashboard or alert. Reviewer-enforced; absence requires a documented follow-up.
- Security and access: 0 = sensitive data handled unsafely, 1 = encryption or access control where needed, 2 = approved masking and least-privilege access reviewed. CI checks for hardcoded secrets; a security owner signs off on anything touching regulated data.
Worked example
Consider a PR that adds a transformation masking a customer email column before it lands in an analytics table, on a dataset of moderate size. Scoring it: tests = 2 (unit tests plus a small integration run), data-quality = 1 (adds a null check but no uniqueness or range assertion), schema = 2 (documented, includes a backfill plan because it changes an existing column), readability = 2, performance = 1 (small change, no profiling numbers attached), observability = 0 (no new metric added for the masking step), security = 2 (masking is exactly what this item exists to reward). Total: 2 + 1 + 2 + 2 + 1 + 0 + 2 = 10 out of a possible 14. Under a threshold like "10 or higher clears with one reviewer," this PR passes with a single approver, but the reviewer should still call out the observability zero explicitly as a fast-follow rather than let a strong total quietly absorb it.
Trade-offs and pitfalls
A pure point-sum threshold can hide exactly that kind of zero on a critical item, so pair the total with a hard floor rule: any single 0 on tests or data-quality forces reviewer sign-off regardless of total. A rubric that is too granular also becomes its own overhead, where reviewers spend more time scoring than reviewing; keep CI-enforceable items automated so a human is only asked to judge genuinely subjective items like readability. Finally, the same rubric should not apply uniformly everywhere: a lighter version fits exploratory or one-off pipelines, and the full rubric belongs on anything that feeds a production or shared table.
That is every published Code Review and Working with Existing Codebases question for Applied Scientist so far. Browse the other topics in this category, or practice this one interactively.