-
Notifications
You must be signed in to change notification settings - Fork 7
Detectors Leakage
This is the core of the library. Five checks, one question: does this feature know something it could not have known at prediction time?
| Function | Code | Severity | Module |
|---|---|---|---|
audit_equivalence |
LEK001 | CRITICAL | leakage/equivalence.py |
audit_correlation_leakage |
LEK002 | WARNING | leakage/correlation.py |
audit_temporal_leakage |
LEK003 | WARNING | leakage/temporal.py |
audit_asof_leakage |
LEK004 | CRITICAL | leakage/asof.py |
audit_combination_leakage |
LEK005 | CRITICAL | leakage/combination.py |
LEK001–LEK003 and LEK005 require target=. Without it, scan() skips them silently. LEK004 is target-independent but requires you to supply available_at=.
For panel data there is a sixth leakage check, PNL002, which catches leaks that live in the relationship between entities.
Data leakage is when information that would not be available at prediction time leaks into your training features. The model learns from it, scores brilliantly in backtesting, and fails completely in production — because in production that information genuinely is not there.
It is dangerous specifically because it produces good results. A bug that makes your model worse gets found immediately. A bug that makes your model look excellent gets shipped.
tsauditor detects five flavours:
- The feature is the target. Same information, different column name. (LEK001)
- The feature aligns with the target's future. Its correlation peaks at a positive lag. (LEK002)
- The feature knows the future better than persistence explains. The signature of a centered window. (LEK003)
- The feature was published after the timestamp it sits at. A point-in-time violation. (LEK004)
- No single feature leaks, but a group of them rebuilds the target. (LEK005)
For panel data, PNL002 adds a sixth: a leak living in the relationship between entities.
Every leakage detector uses Spearman correlation or AUC, never Pearson. This is a deliberate decision and the single most important design choice in the library.
Pearson correlation measures linear association. But the question these checks ask is not "is this relationship linear?" It is "does this feature determine the target?" — a question about determinism, not linearity.
The gap between those two questions is exactly where the OGDC bug lived.
When you compute Pearson correlation between a continuous feature and a binary 0/1 target, you get the point-biserial correlation. This statistic has a hard mathematical ceiling of roughly:
√(2/π) ≈ 0.798
That ceiling applies even when the feature perfectly determines the target. A feature whose sign defines the label — Direction = 1 if ChangeP > 0 else 0 — cannot score above about 0.8 in Pearson, no matter how perfect the relationship is.
So a leakage detector with a sensible-looking Pearson threshold of 0.95, or even 0.85, would score the OGDC leak at ~0.77 and wave it through. Here is that failure, demonstrated:
import numpy as np
from scipy.stats import pearsonr
rng = np.random.default_rng(7)
change = rng.normal(0, 1, 300)
direction = (change > 0).astype(int) # the target is literally defined by the feature
r, _ = pearsonr(change, direction)
print("Pearson correlation:", round(r, 4))Pearson correlation: 0.7733
A perfect, definitional leak scores 0.77. Under any reasonable Pearson threshold it passes as clean.
AUC scores the same relationship at exactly 1.0. That is why LEK001 uses AUC for binary targets. Spearman is used for continuous targets and for LEK002/LEK003, because it captures any monotonic relationship — including non-linear ones a log or square transform would hide from Pearson — and is robust to outliers.
A feature that near-deterministically reproduces the target at lag 0. The feature is the target, arrived at by a different route.
This is the flagship check, rated CRITICAL, and the one the library was written for.
audit_equivalence(
df: pd.DataFrame,
target: str,
continuous_threshold: float = 0.95,
binary_threshold: float = 0.95,
min_obs: int = 30,
domain: Optional[str] = None,
) -> List[Issue]| Parameter | Type | Default | What it does |
|---|---|---|---|
df |
pd.DataFrame |
required | Input data |
target |
str |
required | Target column name. Raises ValueError if absent. |
continuous_threshold |
float |
0.95 |
|Spearman ρ| threshold, used when the target is continuous |
binary_threshold |
float |
0.95 |
AUC-separation threshold, used when the target is binary. Loosen toward 0.90 to tolerate label noise. |
min_obs |
int |
30 |
Minimum pairwise-complete observations required to trust a score |
domain |
str or None
|
None |
Accepted for API consistency; has no effect |
Step 1 — classify the target. Count distinct non-null values.
- Fewer than 2 → return immediately. A constant target has nothing to reproduce.
- Exactly 2 → binary. The two categories are sorted by their string form and mapped to 0.0 and 1.0, so this works for numeric
0/1and categorical"up"/"down"alike. - More than 2 → continuous. Must be numeric, or
ValueError.
Step 2 — pick the metric.
| Target type | Metric | Threshold |
|---|---|---|
| Binary | AUC separation, max(AUC, 1 - AUC)
|
binary_threshold |
| Continuous | |Spearman ρ| | continuous_threshold |
Step 3 — score each numeric feature against the target, using only pairwise-complete rows with inf treated as missing.
Understanding AUC here. AUC is computed via the Mann-Whitney rank statistic, using average ranks so ties are handled correctly. It answers: if I pick one random row where the target is 1 and one where it is 0, how often does the feature rank the class-1 row higher?
- 0.5 — no separation at all. The feature is useless, which is what an unrelated column looks like.
- 1.0 — perfect separation. Every class-1 row outranks every class-0 row.
- 0.0 — also perfect separation, just inverted.
Because 0.0 is as damning as 1.0, the check uses max(AUC, 1 - AUC), making it direction-agnostic. A perfectly inverted leak scores 1.0 and is caught.
Both metrics live on a comparable [0, 1] scale, which is why one threshold of 0.95 is meaningful for either target type.
Binary target:
| Key | Meaning |
|---|---|
metric |
"auc" |
auc |
The raw AUC, before the max() — tells you the direction |
separation |
max(auc, 1 - auc), the value compared against the threshold |
threshold |
Threshold applied |
target_type |
"binary" |
n_obs |
Pairwise-complete observations used |
Continuous target:
| Key | Meaning |
|---|---|
metric |
"spearman" |
spearman_rho |
Signed ρ |
threshold |
Threshold applied |
target_type |
"continuous" |
n_obs |
Pairwise-complete observations used |
- The target has fewer than 2 distinct non-null values
- The feature has fewer than
min_obs(default 30) pairwise-complete observations with the target — a high score from a handful of points is spurious - The feature is constant (zero variance)
- The feature is non-numeric
- For binary targets, only one class is present in the overlapping rows
- The score falls below the threshold
import pandas as pd, numpy as np
from tsauditor.leakage.equivalence import audit_equivalence
rng = np.random.default_rng(7)
n = 300
idx = pd.date_range("2024-01-01", periods=n, freq="D")
change = rng.normal(0, 1, n)
df = pd.DataFrame({
"Direction": (change > 0).astype(int), # target, defined by ChangeP
"ChangeP": change, # the leak
"noise": rng.normal(0, 1, n), # an innocent column
}, index=idx)
for issue in audit_equivalence(df, target="Direction"):
print(issue.column, issue.evidence)ChangeP {'metric': 'auc', 'auc': 1.0, 'separation': 1.0, 'threshold': 0.95, 'target_type': 'binary', 'n_obs': 300}
ChangeP is caught with a perfect AUC of 1.0. noise is correctly left alone. Recall from the section above that Pearson scored this same relationship at 0.7733.
A legitimately excellent feature will be flagged. LEK001 cannot distinguish "this feature is the target in disguise" from "this feature is a genuinely superb predictor that happens to be available in advance." It flags; you judge. The suggestion text says so explicitly: keep it only if you can confirm it is genuinely available at prediction time.
Only lag-0 equivalence is checked. A feature equal to the target shifted by one step is invisible to LEK001. That is LEK002's job.
Only monotonic relationships are found. A feature equal to target² on a symmetric range is perfectly deterministic but not monotonic, and Spearman will not see it.
Multi-feature leakage is not LEK001's job. If no single column reproduces the target but a group of them together does, LEK001 stays silent by design — that is LEK005.
domain does nothing. It is accepted for signature consistency only.
A feature whose correlation with the target is strongest at a positive lag — meaning it aligns better with the target's future than with its present.
audit_correlation_leakage(
df: pd.DataFrame,
target: str,
max_lag: int = 10,
min_correlation: float = 0.1,
min_obs: int = 30,
domain: Optional[str] = None,
) -> List[Issue]| Parameter | Type | Default | What it does |
|---|---|---|---|
max_lag |
int |
10 |
Lags tested in each direction, so the search covers −10 to +10 |
min_correlation |
float |
0.1 |
The peak must reach this magnitude, so noise peaks are ignored |
min_obs |
int |
30 |
Minimum overlapping observations at a given lag for it to count |
domain |
str or None
|
None |
No effect |
Define the cross-correlation at lag τ:
r(τ) = corr( feature_t , target_{t+τ} )
So τ > 0 compares the feature against the target's future. The check sweeps τ from −max_lag to +max_lag, records where |r(τ)| is largest, and flags the feature if that peak occurs at a positive τ and reaches min_correlation.
The logic is straightforward: a legitimate feature carries information from the past or present, so its association with the target should peak at τ ≤ 0. If it peaks in the future, the feature is somehow carrying future information.
Implementation note. The target is rank-transformed once before the loop, and shifted ranks are correlated at each lag. Ranking inside the loop was the previous hot path. Since Spearman is Pearson-on-ranks, this is equivalent and much faster.
| Key | Meaning |
|---|---|
peak_lag |
The positive lag where |r| was maximal |
peak_correlation |
The signed correlation at that lag |
min_correlation |
Threshold applied |
max_lag |
Search range used |
metric |
"spearman" |
- The peak occurs at lag 0 or a negative lag — the normal, healthy case
- The peak magnitude is below
min_correlation - Fewer than
min_obsoverlapping observations at every lag - The feature or target is constant over the overlapping subset
- The target has fewer than 2 distinct values
import pandas as pd, numpy as np
from tsauditor.leakage.correlation import audit_correlation_leakage
rng = np.random.default_rng(7)
n = 300
idx = pd.date_range("2024-01-01", periods=n, freq="D")
y = pd.Series(rng.normal(0, 1, n), index=idx)
df = pd.DataFrame({
"y": y,
"tomorrow_y": y.shift(-1), # tomorrow's target, available today — a leak
"yesterday_y": y.shift(1), # yesterday's target — perfectly legitimate
}, index=idx)
for issue in audit_correlation_leakage(df, target="y"):
print(issue.column, issue.evidence)tomorrow_y {'peak_lag': 1, 'peak_correlation': 1.0, 'min_correlation': 0.1, 'max_lag': 10, 'metric': 'spearman'}
tomorrow_y is caught at exactly lag +1 with correlation 1.0. yesterday_y, which peaks at lag −1, is correctly ignored — using yesterday's value today is not leakage, it is just a lag feature.
This is a suspicion flag, not a proof — and the module says so in its own docstring. In pure cross-correlation, a genuine strong predictor and a lookahead leak produce the same signature: a positive-lag peak. The only separator is magnitude. Real one-step-ahead predictive power in most domains is weak (|r| perhaps 0.05–0.2); leakage is strong (|r| above 0.5). Read peak_correlation and judge accordingly. This is why LEK002 is WARNING and not CRITICAL.
Autocorrelated targets create false peaks. If the target is strongly persistent, a feature correlated with target_t is automatically correlated with target_{t+1} too, and noise can push the peak a step into the future. LEK003 exists precisely to control for this.
Only ±10 lags by default. A leak at lag +15 is invisible unless you raise max_lag.
A feature that leaks at lag 0 and lag +1 may not be flagged. If the lag-0 correlation is even marginally higher, the peak is at 0 and LEK002 stays quiet. LEK001 should catch that case.
A feature that correlates with the target's future more strongly than the target's own persistence can explain — the signature of a centered or forward-looking rolling window.
Here is why a naive "does it correlate with the future?" test is useless on time-series data.
Time-series targets are usually strongly autocorrelated. Today's price predicts tomorrow's price with correlation near 0.99, for no interesting reason — that is just what prices do.
Now take a perfectly honest trailing feature, say a 5-day trailing average. It tracks target_t. And target_t predicts target_{t+1} through sheer persistence. Therefore the honest feature correlates with target_{t+1} too — transitively, innocently.
A naive detector would flag every well-behaved feature you have.
Control for persistence explicitly. The correlation a feature can reach with the future legitimately is bounded by its present association with the target, multiplied by the target's own autocorrelation:
expected(k) = |corr(feature_t, target_t)| × |corr(target_t, target_{t+k})|
observed(k) = |corr(feature_t, target_{t+k})|
excess(k) = observed(k) − expected(k)
If excess(k) exceeds excess_threshold at any lag k in 1..max_lag, the feature knows the future better than persistence alone permits. Something other than persistence is carrying that information — most commonly a window that reaches forward.
All correlations are Spearman.
audit_temporal_leakage(
df: pd.DataFrame,
target: str,
max_lag: int = 5,
excess_threshold: float = 0.1,
min_correlation: float = 0.1,
min_obs: int = 30,
domain: Optional[str] = None,
) -> List[Issue]| Parameter | Type | Default | What it does |
|---|---|---|---|
max_lag |
int |
5 |
Forward lags examined (1..max_lag) |
excess_threshold |
float |
0.1 |
How far above the persistence baseline counts as leakage |
min_correlation |
float |
0.1 |
The observed future correlation must itself reach this |
min_obs |
int |
30 |
Minimum overlapping observations |
domain |
str or None
|
None |
No effect |
Both excess_threshold and min_correlation must be satisfied, so a large excess over a tiny baseline is not enough on its own.
Implementation note. The target's autocorrelations do not depend on any feature, so the shifted target series and their persistence correlations are computed once before the per-feature loop rather than inside it.
| Key | Meaning |
|---|---|
lag |
The lag with the largest excess |
observed_future_corr |
Signed observed correlation at that lag |
excess_over_persistence |
observed − expected at that lag |
excess_threshold |
Threshold applied |
metric |
"spearman" |
- No lag produces an excess reaching
excess_threshold - The observed correlation is below
min_correlation - The present feature-target correlation cannot be computed
- Fewer than
min_obsoverlapping observations - The feature or target is constant
import pandas as pd, numpy as np
from tsauditor.leakage.temporal import audit_temporal_leakage
rng = np.random.default_rng(11)
n = 400
idx = pd.date_range("2024-01-01", periods=n, freq="D")
y = pd.Series(rng.normal(0, 1, n), index=idx)
df = pd.DataFrame({
"y": y,
"roll_trailing": y.rolling(5).mean(), # honest: past only
"roll_centered": y.rolling(5, center=True).mean(), # peeks 2 steps ahead
}, index=idx)
for issue in audit_temporal_leakage(df, target="y"):
print(issue.column, issue.evidence)roll_centered {'lag': 1, 'observed_future_corr': 0.4128, 'excess_over_persistence': 0.4094, 'excess_threshold': 0.1, 'metric': 'spearman'}
The centered window is caught; the trailing window is correctly ignored. The two features are otherwise identical — same input, same width, same aggregation. The only difference is center=True, and that is the entire bug.
Run audit_correlation_leakage on that exact same DataFrame and it reports nothing. Here is why:
lag -3: -0.0098
lag -2: +0.4407 <- the peak is here, at a NEGATIVE lag
lag -1: +0.4164
lag +0: +0.4020
lag +1: +0.4128
lag +2: +0.4204
lag +3: -0.0249
A 5-wide centered window spans two steps back and two steps forward, so its correlation profile is roughly symmetric. The peak lands at lag −2, which looks perfectly innocent to LEK002 — it only fires on a positive-lag peak.
But the correlation of +0.4128 at lag +1 is far above what persistence explains, because white noise has essentially zero autocorrelation. LEK003 sees that excess and flags it.
This is the clearest possible demonstration of why both checks exist. They are not redundant; they catch different failures.
It produces false positives, and you should expect them. In testing, a 11-wide trailing window on a white-noise target was flagged with an excess of 0.10 — right at the threshold. The persistence baseline is itself an estimate, and estimation noise can push an honest feature over the line. Treat a small excess (near 0.1) as weak evidence and a large one (above 0.3) as strong.
Highly autocorrelated targets weaken the check. When the target's persistence is near 1.0, expected is nearly as large as observed can be, so the excess is squeezed toward zero and real leaks can hide. On the random-walk version of the example above, a 5-wide centered window was not flagged — only wider ones were. Financial price levels are exactly this case; consider running the check on returns rather than levels.
Only 5 forward lags by default. A window reaching 10 steps ahead may not be caught at max_lag=5.
The multiplicative baseline is a heuristic, not a theorem. |r0| × |persistence| is a reasonable bound on transitive correlation, not a proven one. It can be too generous or too strict depending on the joint distribution.
A value sitting at a timestamp earlier than the moment it was actually published. Every row before the real release date consumes information that did not yet exist.
Many real-world series describe a reference period but become available later:
- CPI for January is published in mid-February
- Company earnings for Q1 arrive weeks after Q1 ends
- Unemployment, policy rates, GDP — all released on a delay
- News sentiment scores are often computed and backfilled retroactively
If such a value is aligned to its reference date and consumed at that timestamp, your backtest is trading on numbers nobody had yet.
This cannot be detected from the values alone. Whether a CPI figure was knowable on January 31st depends entirely on when the statistics office published it — a fact that exists nowhere in your DataFrame. No amount of statistical cleverness can recover it.
So the check is explicit: you supply the availability information, and tsauditor verifies your data respects it. It never guesses release dates. If your available_at metadata is wrong, the check's conclusion is wrong. That responsibility is yours.
audit_asof_leakage(
df: pd.DataFrame,
available_at: Dict[str, Union[pd.Series, pd.Timedelta]],
min_violations: int = 1,
) -> List[Issue]| Parameter | Type | Default | What it does |
|---|---|---|---|
df |
pd.DataFrame |
required | Must have a DatetimeIndex — the decision times |
available_at |
dict |
required | Per-column availability. Unlisted columns are not checked. |
min_violations |
int |
1 |
Rows required before raising. Default 1: one confirmed lookahead is real leakage. |
A pd.Timedelta — a fixed publication lag.
available_at={"cpi": pd.Timedelta(days=30)}Availability becomes row_timestamp + lag for every row. Because the lag is positive and uniform, every row is a violation — which is the point. It catches the classic "I forgot to shift the macro series" bug in one line.
A pd.Series — real per-row publish timestamps.
available_at={"cpi": actual_release_dates} # must be indexed by df.indexThis is the general and correct form. Real release schedules are ragged — CPI is not published exactly 30 days later every month — and a Series captures that. Rows where the value was genuinely available produce no violation.
If the Series does not align with df.index at all, you get an explicit error rather than a silently empty result.
violation = value is not NaN
AND availability timestamp is known
AND available_at > row timestamp
Then it reports the count, the maximum lookahead in days, and the earliest offending row timestamp.
| Key | Meaning |
|---|---|
n_violations |
Rows using a value before it existed |
max_lookahead_days |
Largest gap between availability and row timestamp, in days (3dp) |
first_violation |
Earliest offending row timestamp |
check |
"as-of" |
- The column is not listed in
available_at - Every value was available at or before its row timestamp
- Violations number fewer than
min_violations - All values in the column are NaN
Raises ValueError if the index is not a DatetimeIndex, if a listed column does not exist, if a Series fails to align, or if the spec is neither a Series nor a Timedelta.
import pandas as pd, numpy as np
from tsauditor.leakage.asof import audit_asof_leakage
rng = np.random.default_rng(7)
idx = pd.date_range("2024-01-01", periods=300, freq="D")
df = pd.DataFrame({"cpi": rng.normal(3, 0.2, 300)}, index=idx)
# CPI is published about 30 days after the period it describes
for issue in audit_asof_leakage(df, available_at={"cpi": pd.Timedelta(days=30)}):
print(issue.column, issue.evidence)cpi {'n_violations': 300, 'max_lookahead_days': 30.0, 'first_violation': '2024-01-01 00:00:00', 'check': 'as-of'}
All 300 rows are violations, because the column sits at reference dates while the values were not available until 30 days later. The fix is to shift the column forward to its release schedule — not to drop it. The data is fine; the alignment is wrong.
It is only as correct as your metadata. Wrong available_at, wrong answer. There is no way around this.
A fixed Timedelta is a blunt instrument. It flags every row identically and cannot represent a real, irregular release calendar. Use it as a quick smoke test; use a Series for anything serious.
Columns you do not list are never checked. Silence from LEK004 means nothing unless you declared the columns.
Revisions are not modeled. Many macro series are published, then revised weeks later. This check verifies first availability only; it has no concept of a value changing after release.
A group of two or three features that together reconstruct the target, while none does alone — additively (sums, differences) or multiplicatively (products, ratios).
Every check above is univariate: each feature is scored against the target on its own. That misses a whole class of real bug:
target = (high + low) / 2
target = revenue - costs
target = price * quantity
target = numerator / denominator
target = a + b + cNo input is near-deterministic by itself, so LEK001 stays silent — but the group rebuilds the target exactly.
The canonical shape is a difference. If x1 and x2 are independent with similar variance and target = x1 - x2, each correlates with the target at only about 0.7 — far below LEK001's 0.95 threshold — while the pair explains it perfectly.
This is not hypothetical. In the OGDC file, MACD_hist is exactly MACD - MACD_signal (verified to 3e-15). Point target="MACD_hist" at tsauditor and LEK005 reports an adjusted R² of 1.0000 for that pair, where the best single column reaches only 0.12.
audit_combination_leakage(
df: pd.DataFrame,
target: str,
threshold: float = 0.95,
min_obs: int = 30,
max_features: Optional[int] = None,
max_reported: int = 10,
max_group_size: int = 3,
gate: float = 0.30,
max_candidates_per_level: int = 200,
domain: Optional[str] = None,
) -> List[Issue]| Parameter | Default | What it does |
|---|---|---|
threshold |
0.95 |
Adjusted R² at or above which a group is flagged. Matches LEK001. |
min_obs |
30 |
Minimum complete rows to score a group |
max_features |
None |
Cap on features considered. The pair scan is O(k²) — ~0.27s for 100 features. |
max_reported |
10 |
Cap on findings, best first, so a family of derived columns can't flood the report |
max_group_size |
3 |
Largest group searched. 2 = pairs only; raise to 4+ for larger identities. |
gate |
0.30 |
Adjusted R² a group must reach before it is extended by another column |
max_candidates_per_level |
200 |
Cap on groups carried to the next level, best first — bounds cost |
For a candidate group, fit target ~ 1 + columns by ordinary least squares and take the adjusted R². Two algebraic forms are tried:
- linear — catches sums, differences and weighted combinations
-
log — the same fit on
logof the absolute target and columns, which catches products and ratios, sincelog(a*b) = log a + log b. Absolute values mean signed products and ratios are recovered too (|a*b| = |a|*|b|). Skipped when any value touches zero, sincelogof a near-zero would dominate the fit.
Measured coverage (adjusted R², n=500):
| target | linear | log |
|---|---|---|
x1 - x2 |
1.0000 | 0.0112 |
x1 * x2 |
0.9287 | 1.0000 |
x1 / x2 |
0.8304 | 1.0000 |
| unrelated control | −0.0026 | −0.0038 |
Neither form alone suffices. An interaction term (x_i * x_j as a third predictor) was tested as an alternative and rejected: it catches products but not ratios (0.897), and roughly doubles the chance-level R².
The form used is reported in evidence["form"].
Why plain OLS, not the rank methods used everywhere else in this module. The leakage here is arithmetic, and ranking destroys it. On the canonical target = x1 - x2 case, raw adjusted R² is 1.0000 while the rank-transformed version scores 0.9410 — under the threshold, missing the leak entirely. Rank methods answer "is this monotonic?"; the question here is "do these columns reconstruct the target?"
Adjusted rather than raw R² is used because it penalises extra predictors, keeping the null distribution tight across many candidate groups.
lstsq (pseudo-inverse) is used rather than a normal-equation solve because candidate groups are frequently collinear — high/low, a level and its lag — which would make X'X singular.
Scanning every triple would be C(k,3) fits — 161,700 for 100 features — and quadruples are worse. Both would badly inflate the multiple-comparison problem.
Instead the search deepens iteratively. A group is extended by one more column only when it reaches gate without reaching the flagging threshold — that is exactly the signature of a sub-group of a larger identity:
| true identity | best sub-group score | clears the 0.30 gate? |
|---|---|---|
a + b + c |
pair scores ~0.71 | yes |
a + b + c + d |
pair ~0.49, triple ~0.74 | yes |
On random data nothing clears the gate at all, so deeper levels cost nothing and add no false positives. The gate was verified not to block genuine identities across equal, moderately unequal, cancelling and collinear component shapes.
max_group_size defaults to 3. Raise it to find larger identities:
audit_combination_leakage(df, target="y", max_group_size=4)It is not the default because each level is free on clean data but costs time on frames where many features partially explain the target. max_candidates_per_level (default 200) bounds that: without it, 40 mutually correlated features took 21s at depth 4; with it, 0.7s. The trade-off is that in such a frame a genuine identity outside the top 200 sub-groups could be missed.
A group is not reported when a subset of it was already reported — that would be the same finding with a redundant column attached.
A pair is skipped when either column alone already reaches the threshold.
Without this, one leaky column poisons the whole report: if leak alone reproduces the target, then every pair containing leak also hits R² 1.0, producing k−1 findings for something LEK001 already reported once. That case belongs to LEK001; LEK005 is only for leakage that emerges from the combination.
- Either column alone already explains the target (LEK001's job)
- Fewer than
min_obscomplete rows for the pair - The target is constant, non-numeric, or absent
- Fewer than two usable numeric features
- Either column is constant
import pandas as pd, numpy as np
from tsauditor.leakage.combination import audit_combination_leakage
rng = np.random.default_rng(0)
n = 400
x1 = rng.normal(0, 1, n)
x2 = rng.normal(0, 1, n)
df = pd.DataFrame({
"target": x1 - x2, # neither input determines this alone
"x1": x1,
"x2": x2,
"noise": rng.normal(0, 1, n),
}, index=pd.date_range("2024-01-01", periods=n, freq="D"))
for issue in audit_combination_leakage(df, target="target"):
print(issue.evidence){'metric': 'adjusted_r2', 'form': 'linear', 'group': ['x1', 'x2'], 'group_size': 2,
'group_adjusted_r2': 1.0, 'best_single_adjusted_r2': 0.4613,
'threshold': 0.95, 'n_obs': 400}
best_single_adjusted_r2 of 0.46 is the key number: neither column comes close on its own, which is exactly why every other leakage check missed it.
The same call catches a product, a ratio, and a three-column identity:
a = rng.uniform(2, 10, n) # strictly positive, so the log form applies
b = rng.uniform(2, 10, n)
c = rng.uniform(2, 10, n)| target | reported group | form |
adjusted R² |
|---|---|---|---|
a - b |
['a', 'b'] |
linear | 1.0 |
a * b |
['a', 'b'] |
log | 1.0 |
a / b |
['a', 'b'] |
log | 1.0 |
a + b + c |
['a', 'b', 'c'] |
linear | 1.0 |
| unrelated control | — | — | no finding |
Search depth is capped by max_group_size (default 3). Larger identities are found by raising it, at some cost on frames with many partially-explanatory features.
False-positive profile is good, and was measured. On random targets with independent random features, the largest adjusted R² reached by chance was 0.075 for pairs (50 features, 1,225 pairs, 100 rows), typically below 0.03; the log form behaves the same (max 0.028). No triple is ever evaluated on random data, because no pair clears the gate. Innocent but highly collinear pairs (r ≈ 0.96) score ~0.00 against an unrelated target. A real 24-column financial dataset produced zero findings for its actual target.
Additive and multiplicative only. Between the linear and log forms this covers sums, differences, products and ratios, signed or not. Genuinely non-monotonic constructions — target = x1² + sin(x2) — are not found, and would require either a fitted model or a guessed basis expansion; both would cost the library its "no model, no hyperparameters" property.
The log form needs values away from zero. Signed data is fine — absolute values are used — but a column containing an exact zero falls back to the linear form, so a product involving it may be missed.
Cost is O(k²) for the pair scan. About 0.27s for 100 features, 0.07s for 50. With several hundred columns, set max_features. Triples add essentially nothing on clean data.
It flags relationships, not culprits. LEK005 tells you the target is reconstructible from a group of columns. Whether that is leakage or a legitimate identity you already knew about is your call.
A dominated group is deferred to LEK001. target = 100a + b + c looks like a three-way identity, but a alone explains 99.98% of it. The single-feature guard makes LEK005 stay silent so LEK001 owns that finding.
| Code | Confidence | How to read it |
|---|---|---|
| LEK001 | Very high | An AUC of 1.0 or ρ of 0.99 is not a judgement call. Investigate immediately. |
| LEK005 | Very high | An adjusted R² near 1.0 with a low best-single score is an arithmetic identity, not a coincidence. |
| LEK004 | High, conditional on your metadata | The logic is deterministic; only your available_at can be wrong. |
| LEK002 | Medium | Read peak_correlation. Above 0.5 is alarming; near 0.1 is probably noise. |
| LEK003 | Medium | Read excess_over_persistence. Above 0.3 is strong; near 0.1 may be estimation noise. |
Remaining gaps. LEK001–LEK004 are univariate. LEK005 covers groups of two or three by default (raise max_group_size for more), additive and multiplicative, signed or not — but not non-monotonic constructions like x1² + sin(x2). For panel data, PNL002 covers leaks living between entities. tsauditor reduces your risk substantially; it does not eliminate it.