I opened a dataset. What now?

A router for an unfamiliar table. What is one row, is there a clock, do entities repeat, what shape is the target, and which symptom are you seeing. Each answer sends you to the page and the worked example that deal with it.

Updated 28 Sept 20265 code blocks verifiedrouterstart-herediagnosticsunfamiliar-datasymptomsvalidationleakagebaseline
Toolkit

On a time-stamped table, the toolkit does the panel triage below in one call, then the cleaning log, the split and the scoring: one file, kit.py, and a notebook that calls it in order.

Every table looks new for about ten minutes. After that it is one of a small number of shapes, and each shape has a known set of traps, a natural baseline, and a validation design that answers the question actually being asked. This page is the index of those shapes. Read the first four questions in order; then find your symptom in the last table.

One screen of triage

The brief defines the columns. Don’t research theory before your first number: the first 30 minutes are rows, key, missing, wrong.

Before anything else, one block that answers most of the questions below on any table. Here it runs on a hotel-booking file; swap the file name and the column names.

import pandas as pd, numpy as np

df = pd.read_csv("hotel_bookings.csv")
print(df.shape)
print("nulls:", df.isna().sum()[lambda s: s > 0].to_dict())
print("exact duplicate rows:", df.duplicated().sum(), "(%.1f%%)" % (100 * df.duplicated().mean()))
obj = df.select_dtypes("object").nunique().sort_values(ascending=False)
print("categorical cardinality:", obj.head(6).to_dict())
print("numeric columns that look like ids or codes:", [c for c in df.select_dtypes("number") if df[c].nunique() == len(df)])
print("target: is_canceled ->", df["is_canceled"].value_counts(normalize=True).round(3).to_dict())
print("arrival years:", df["arrival_date_year"].value_counts().sort_index().to_dict())
(119390, 32)
nulls: {'children': 4, 'country': 488, 'agent': 16340, 'company': 112593}
exact duplicate rows: 31994 (26.8%)
categorical cardinality: {'reservation_status_date': 926, 'country': 177, 'arrival_date_month': 12, 'assigned_room_type': 12, 'reserved_room_type': 10, 'market_segment': 8}
numeric columns that look like ids or codes: []
target: is_canceled -> {0: 0.63, 1: 0.37}
arrival years: {2015: 21996, 2016: 56707, 2017: 40687}

Six lines and the table has already declared itself: a binary target with a 37% positive rate (not rare, but not balanced), 27% exact duplicate rows, two high-cardinality columns with most values missing, and a time axis across three years. Each of those is a fork in the road below.

If it’s a panel (entity × time)

When one row is one entity at one time, the block above misses the checks that matter most: the key, each entity’s span, and the spacing in time. Here it runs on a daily equity file with one row per ticker per day. ticker and date are placeholders for the file’s own entity and time columns.

df = pd.read_csv("all_stocks_5yr.csv").rename(columns={"Name": "ticker"})
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values(["ticker", "date"]).reset_index(drop=True)   # sort before any shift/diff/pct_change
print("duplicate keys:", df.duplicated(["date", "ticker"]).sum(),
      "| exact duplicate rows:", df.duplicated().sum())
span = df.groupby("ticker")["date"].agg(["min", "max", "count"])  # who starts late, who ends early
print(span.sort_values("count").head(10))
gap = df.groupby("ticker")["date"].diff()
print((gap.dt.total_seconds() / 3600).value_counts().head(10))   # gaps in hours; don't use .dt.days on sub-daily data
print(df.groupby("date")["ticker"].nunique().describe())          # entities per timestamp over time
duplicate keys: 0 | exact duplicate rows: 0
              min        max  count
ticker                             
APTV   2017-12-05 2018-02-07     44
DWDP   2017-09-01 2018-02-07    109
BHF    2017-07-17 2018-02-07    143
BHGE   2017-07-03 2018-02-07    152
DXC    2017-04-03 2018-02-07    215
HLT    2017-01-04 2018-02-07    276
EVHC   2016-12-02 2018-02-07    297
FTV    2016-07-01 2018-02-07    404
UA     2016-04-07 2018-02-07    464
WLTW   2016-01-05 2018-02-07    528
date
24.0     484808
72.0     111568
96.0      16754
48.0       5398
144.0         3
408.0         3
120.0         1
Name: count, dtype: int64
count    1259.000000
mean      491.691819
std         8.281534
min       472.000000
25%       485.000000
50%       492.000000
75%       498.000000
max       505.000000
Name: ticker, dtype: float64
  • Duplicate keys greater than exact duplicate rows means some copies disagree. Look at them, pick a rule, and check that the rule doesn’t change the result.
  • Entities that start late but none that end early means the universe is probably only today’s survivors. Say so on the last slide.
  • One gap size that recurs at the same timestamp across every entity is one market-wide event, such as an outage or a holiday, not many separate problems.

Then a few more lines, before anything is plotted or ranked:

print("bar size:", df.groupby("ticker")["date"].diff().median())      # hourly? daily? label everything with this
df["ret"] = df.groupby("ticker")["close"].pct_change()
print(df["ret"].quantile([0.001, 0.01, 0.5, 0.99, 0.999]).round(4).to_dict())   # look at the tails before plotting
flat = (df["open"] == df["high"]) & (df["high"] == df["low"]) & (df["low"] == df["close"])
print("flat bars:", flat.sum(), "| of which zero volume:", (flat & (df["volume"] == 0)).sum())
bar size: 1 days 00:00:00
{0.001: -0.0904, 0.01: -0.0429, 0.5: 0.0006, 0.99: 0.0421, 0.999: 0.0917}
flat bars: 1 | of which zero volume: 0
  • Bar size first. Call it an hourly return if the bars are hourly. Mislabelling the unit makes every later number sound wrong (“daily std 0.5”).
  • Tails before a histogram. If the 0.1% and 99.9% quantiles are sane but the max is absurd, a few bad rows are driving it. A +99x return followed by −0.99 on the next bar is one bad print, not two events. Fix the row, then plot.
  • Flat zero-volume bars are stale, not impossible. Nothing traded, so the price just carried over. They produce a return of 0 that looks like information in a cross-sectional rank. Drop them or mark them, and say which.
  • A row-count filter needs a reason. groupby("ticker").size() followed by a threshold removes late listings along with broken tickers. Look at the span table before filtering by count.

Last, check that each bar agrees with itself:

bad = (df["close"] > df["high"]) | (df["close"] < df["low"]) | (df["high"] < df["low"])
print("close outside [low, high]:", bad.sum())
df = df[~bad]
close outside [low, high]: 9
  • A close outside its own bar’s range is a bad print, not a price. Drop the rows and say how many. One bad print can make the file’s largest return; here a close at half the real price gives −49% then +101%.

When a count doesn’t add up, print the rows

When a check returns a count that doesn’t make sense, print the rows behind it before guessing why.

odd = df[df["volume"] == 0]                          # whatever set looks odd
print(df.merge(odd[["date", "ticker"]]))            # every row sharing those keys
print(odd["date"].value_counts().head())            # do they share a timestamp?
        date  open  high  low  close  volume ticker       ret
0 2016-01-12   NaN   NaN  NaN  88.55       0    DHR  0.001131
1 2016-07-01   NaN   NaN  NaN  49.54       0    FTV       NaN
2 2016-01-12   NaN   NaN  NaN  52.43       0      O  0.003061
3 2016-04-07   NaN   NaN  NaN  41.56       0     UA       NaN
date
2016-01-12    2
2016-07-01    1
2016-04-07    1
Name: count, dtype: int64

On this file it prints 4 zero-volume rows whose open, high and low are all NaN, so printing them explains them at once: only a close was recorded.

  • Two rows back for one key means it’s a duplicate, and the copies may disagree. That is not a new problem.
  • Many rows on one timestamp means one market-wide event, not many separate faults.
  • Neither means you look at the columns of those rows until one of them explains it.

1. What is one row?

One row is…Which meansGo to
one point in time (a day, an hour) for one thinga time series; the neighbours of a row are correlated with it and the level may driftchoosing a split, load forecasting
one entity at one time (a stock on a date, a store in a week, a patient at a visit)a panel: both a time axis and repeated entities; two ways to leaktwelve sites, one sky, single series or panel
one event or transaction (a booking, a call, a trip, a sale)usually time-stamped; the entity behind it may repeat; post-event columns are likelyprediction boundaries, hotel cancellations
one entity, once (a house, a customer snapshot)static tabular data; a random split may be fine unless the entity has near-duplicatesmissing values that mean something
repeated measurements of the same entitythe entity explains much of the variance; a random split measures recognition, not predictiona model that learns who, not how ill

If you cannot say what one row is in a sentence, stop and find out. Half of the later mistakes are a wrong answer to this question. See first-pass dataset interrogation.

2. Is there a timestamp?

If yes, answer these before building a feature:

  • What would be known at the moment of prediction? Every column is either known before, known at, or observed after that moment. Sort them. → prediction boundaries
  • Is this forecasting or merely time-stamped? Forecasting means the target is the series’ own future; the baseline is then “the last value” or “the same time last cycle”, and a model has to beat that. → load forecasting, traffic against a seasonal baseline
  • Should the split be chronological? Almost always. A shuffled split on drifting data is the single most common source of a number that is too good. → the random split that looked great, chronological splits with an embargo
  • Are there repeated cycles? Hour of day, day of week, month. Lags at the cycle length (24, 168, 12) are the features that carry them, and “same time last cycle” is the baseline.
  • Could a rolling calculation see the future? Centred windows, full-sample normalisation, targets that reach past the split. → forward targets and look-ahead leakage
  • Does the level move? Plot the monthly mean of the target. If it trends, a model without level-carrying features will be wrong in one direction for the whole test period.

3. Are there repeated entities?

Customer, product, store, machine, patient, ticker, site, user. If an identifier column has fewer unique values than rows, decide which question is being asked:

  • Another observation for a known entity, where the entity’s own history is a legitimate feature and usually the best baseline; or
  • A never-seen entity, where entity identity, and anything that proxies for it, must be kept out of the model, and the split must hold out whole entities.

Then run the variance decomposition: how much of the target’s variance is between entities and how much within. → a model that learns who, not how ill (entity explains 93%), twelve sites, one sky (entity explains almost nothing), choosing a split.

4. What does the target look like?

TargetFirst numbers to computeBaselineWatch forExample
continuous, roughly symmetricmean, sd, tailstrain mean; last value if a seriesoutliers driving the fittaxi tips
continuous, heavily right-skewedmedian vs mean, maxmedian; fit on log(1 + y)MAE and RMSE telling different stories; residual variance growing with the fitted valuehouse prices
a countshare of zeros, maxseasonal naive; mean by cellinteractions between calendar factors; skewbike rentals, traffic
binary, balanced-ishpositive sharemajority class; constant probabilitypost-event columnshotel cancellations
binary, rare positivepositive share, count of positivesconstant probability; log-loss of the base rateaccuracy that equals the base rate; threshold at 0.5 predicting nothingbank marketing, flight delays
the series’ own futureautocorrelation at 1 and at the cycle lengthpersistence; seasonal naivea model that barely beats yesterdayload forecasting
no obvious targetwhat varies most; what the rows cluster onnoneinventing a target that is really an identifierextreme values
a ranking or a signrank correlation; hit rate against the majoritymajority signoverlapping targets inflating t-statisticsevaluating signals

Metrics are chosen by this table, not by habit. → classification metrics, logistic regression with a majority-class baseline.

5. Symptoms

Arrive with the symptom; leave with the page.

SymptomMost likely causeWorked example
My random split is excellent and my chronological split is badthe level of the series moved and the shuffle let the model see itbike rentals
My random split is excellent and holding out entities is badthe model recognises entities; identity proxies in the featuresParkinson’s subjects
One feature makes the model absurdly accurateit is recorded after the outcome, or it is arithmetic on the targetbank marketing: call duration, taxi tips: total
Accuracy is 90% and nobody is impressedthe positive class is 10%; accuracy is measuring the base ratebank marketing
The model never predicts the positive classthreshold 0.5 on a rare class; the threshold is a business decisionhotel cancellations
The probabilities are right on average in test and wrong in usethe base rate changed after the training periodbank marketing, 2010, distribution shift
Performance falls apart in the last monthsdrift, a regime change, or a calendar column that memorised campaignsdistribution shift
The sophisticated model barely beats “same as last week”the seasonal naive is the real competitor and it is strongtraffic volume, load forecasting
Pearson correlation is near zero but the scatter has structurea nonlinear or non-monotone relationship, or an interactionhour × working day, Pearson versus Spearman
A linear model is far behind a tree on a handful of featuresan interaction the linear model cannot expressbike rentals, section 8
Lots of missing valuesfind out whether absence means “none”, “not recorded” or “not applicable”, and whether it tracks the targethouse prices, missing values
A column is 90% “unknown” or −1a sentinel; treat as a category, never as a numberbank marketing: pdays
Hundreds or thousands of categoriesone-hot explodes; use frequencies or leak-safe target encoding, and handle unseen levelsflight delays: destinations and aircraft
Duplicate rowstwins across train and test inflate every score; decide what a duplicate means before droppinghotel cancellations
A giant right tail in the targetmodel log(1 + y); report MAE next to RMSE; check whether a few rows drive the fithouse prices, extreme values
The best of twenty features looks significantselection on the same sample; needs a clean hold-outselection bias from the inside
The t-statistic is in the hundredsa leak, almost always the wrong shiftforward targets and leakage
Everything looks fine and I do not trust itrun the checklistdebugging a model that looks too good

6. Then

Freeze the protocol (target, features, split, score, baseline) before fitting anything: pre-analysis protocol. If the time available is fixed, pace it: analysing a new dataset in a fixed block of time.