leakprobe 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leakprobe-0.1.0/LICENSE +21 -0
- leakprobe-0.1.0/PKG-INFO +224 -0
- leakprobe-0.1.0/README.md +196 -0
- leakprobe-0.1.0/pyproject.toml +37 -0
- leakprobe-0.1.0/setup.cfg +4 -0
- leakprobe-0.1.0/src/leakprobe/__init__.py +19 -0
- leakprobe-0.1.0/src/leakprobe/core.py +150 -0
- leakprobe-0.1.0/src/leakprobe/perturb.py +64 -0
- leakprobe-0.1.0/src/leakprobe/report.py +109 -0
- leakprobe-0.1.0/src/leakprobe.egg-info/PKG-INFO +224 -0
- leakprobe-0.1.0/src/leakprobe.egg-info/SOURCES.txt +14 -0
- leakprobe-0.1.0/src/leakprobe.egg-info/dependency_links.txt +1 -0
- leakprobe-0.1.0/src/leakprobe.egg-info/requires.txt +9 -0
- leakprobe-0.1.0/src/leakprobe.egg-info/top_level.txt +1 -0
- leakprobe-0.1.0/tests/test_core.py +178 -0
- leakprobe-0.1.0/tests/test_perturb.py +38 -0
leakprobe-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raunak Sood
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
leakprobe-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leakprobe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing.
|
|
5
|
+
Author: Raunak Sood
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/quantraunak/leakprobe
|
|
8
|
+
Project-URL: Issues, https://github.com/quantraunak/leakprobe/issues
|
|
9
|
+
Keywords: leakage,data-leakage,machine-learning,testing,time-series,point-in-time,feature-engineering
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
23
|
+
Requires-Dist: numpy>=1.26; extra == "dev"
|
|
24
|
+
Provides-Extra: examples
|
|
25
|
+
Requires-Dist: numpy>=1.26; extra == "examples"
|
|
26
|
+
Requires-Dist: openpyxl>=3.1; extra == "examples"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# leakprobe
|
|
30
|
+
|
|
31
|
+
[](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [](https://pypi.org/project/leakprobe/) [](https://pypi.org/project/leakprobe/)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
**Find features that read data they were never supposed to see.**
|
|
35
|
+
|
|
36
|
+
Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
|
|
37
|
+
something that wasn't knowable yet, nothing throws, no number looks implausible,
|
|
38
|
+
and your model gets better. You find out in production, if you find out at all.
|
|
39
|
+
|
|
40
|
+
`leakprobe` finds it without needing to know the right answer. It needs one
|
|
41
|
+
thing you already know: **a change to your data that your features must be
|
|
42
|
+
invariant to.**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install leakprobe
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import leakprobe as lp
|
|
50
|
+
|
|
51
|
+
report = lp.check(
|
|
52
|
+
compute=build_features, # (sources) -> DataFrame of features
|
|
53
|
+
sources={"events": events, "tickets": tickets},
|
|
54
|
+
timestamps={"events": "occurred_at", "tickets": "resolved_at"},
|
|
55
|
+
declared={
|
|
56
|
+
"total_spend": ["events"],
|
|
57
|
+
"event_count": ["events"],
|
|
58
|
+
"tickets_resolved": ["tickets"],
|
|
59
|
+
"avg_severity": ["tickets"],
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
report.raise_for_leaks() # fails your test suite if anything leaked
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
4 features x 2 sources
|
|
67
|
+
|
|
68
|
+
feature events tickets
|
|
69
|
+
total_spend reads it exactly 0
|
|
70
|
+
event_count reads it exactly 0
|
|
71
|
+
tickets_resolved exactly 0 reads it
|
|
72
|
+
avg_severity exactly 0 bypass?
|
|
73
|
+
|
|
74
|
+
No undeclared dependencies.
|
|
75
|
+
|
|
76
|
+
Declared, but did not respond to the source's clock:
|
|
77
|
+
- avg_severity declares tickets but did not move when its clock did -- it
|
|
78
|
+
reads the source without consulting availability, or the declaration is stale
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That last line is a real bug. `avg_severity` filters tickets on `opened_at`
|
|
82
|
+
instead of `resolved_at`, so tickets that were still open at scoring time leak
|
|
83
|
+
in — exactly the ones that predict churn. Nothing about the code looks wrong.
|
|
84
|
+
|
|
85
|
+
## Try it on real data
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install leakprobe openpyxl
|
|
89
|
+
python examples/online_retail.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
|
|
93
|
+
customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
|
|
94
|
+
built as of a cutoff, two of them wrong in the two ways temporal leakage
|
|
95
|
+
actually happens. Neither raises. Both are caught:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
feature orders returns
|
|
99
|
+
total_spend reads it exactly 0
|
|
100
|
+
order_count reads it exactly 0
|
|
101
|
+
recency_days reads it exactly 0
|
|
102
|
+
return_count exactly 0 reads it
|
|
103
|
+
avg_unit_price bypass? exactly 0
|
|
104
|
+
net_spend reads it LEAK
|
|
105
|
+
|
|
106
|
+
1 undeclared dependencies:
|
|
107
|
+
- net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
|
|
108
|
+
|
|
109
|
+
Declared, but did not respond to the source's clock:
|
|
110
|
+
- avg_unit_price declares orders but did not move when its clock did
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`avg_unit_price` is missing one cutoff filter, so it averages invoices that had
|
|
114
|
+
not happened yet. `net_spend` reaches into the returns table without declaring
|
|
115
|
+
it, silently inheriting that table's latency. One dropped subscript and one
|
|
116
|
+
undeclared read -- the two shapes this bug takes in production.
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
Four steps, and no ground truth anywhere in them.
|
|
121
|
+
|
|
122
|
+
1. **Move when a source became knowable.** Not its values — only its
|
|
123
|
+
availability timestamp. `delay` pushes it later, which can only ever remove
|
|
124
|
+
information.
|
|
125
|
+
2. **Recompute every feature.**
|
|
126
|
+
3. **Compare, exactly.** A feature that genuinely cannot read that source gets
|
|
127
|
+
the identical input arrays through the identical code and returns bit-identical
|
|
128
|
+
floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
|
|
129
|
+
of a dependency, not a number you have to squint at.
|
|
130
|
+
4. **Check what moved against what you declared.** Anything in one list and not
|
|
131
|
+
the other is the finding.
|
|
132
|
+
|
|
133
|
+
Two kinds of finding:
|
|
134
|
+
|
|
135
|
+
- **`leak`** — moved, but doesn't declare the source. It has a dependency you
|
|
136
|
+
didn't know about. In a temporal pipeline, an unknown dependency on *when*
|
|
137
|
+
data arrived is look-ahead.
|
|
138
|
+
- **`bypass?`** — declares the source but didn't move when that source's clock
|
|
139
|
+
did. It's reaching the data by a path that ignores availability, which is how
|
|
140
|
+
look-ahead usually gets in. Not a failure on its own. Worth reading.
|
|
141
|
+
|
|
142
|
+
Before any of that, `check` runs `compute` twice on untouched inputs and refuses
|
|
143
|
+
to continue if the two runs disagree. A nondeterministic pipeline makes every
|
|
144
|
+
result below it noise, so that's a hard error rather than a warning.
|
|
145
|
+
|
|
146
|
+
## Declaring nothing
|
|
147
|
+
|
|
148
|
+
You don't have to write the `declared` map. Leave it out and every real timing
|
|
149
|
+
dependency is reported:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
report = lp.check(compute, sources, timestamps, declared={})
|
|
153
|
+
for f in report.leaks:
|
|
154
|
+
print(f.feature, "reads", f.source)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
That's the fastest way to answer "what does this pipeline actually depend on"
|
|
158
|
+
for code you inherited, which is usually a shorter list than the author believed.
|
|
159
|
+
|
|
160
|
+
## In CI
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
def test_no_temporal_leakage():
|
|
164
|
+
lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The declaration map becomes the thing code review argues about, which is where
|
|
168
|
+
that argument belongs.
|
|
169
|
+
|
|
170
|
+
## Perturbations
|
|
171
|
+
|
|
172
|
+
| | |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
|
|
175
|
+
| `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
|
|
176
|
+
| `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## What it will not catch
|
|
183
|
+
|
|
184
|
+
**Dependencies that don't flow through a timestamp.** The perturbation moves
|
|
185
|
+
availability, so it reveals as-of joins, merges on a date, and windows anchored
|
|
186
|
+
to one. A feature that reads a source's values with no reference to when they
|
|
187
|
+
arrived is invariant to it and will not be flagged. In practice this costs less
|
|
188
|
+
than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
|
|
189
|
+
catching are the detectable ones. The boundary is asserted in the test suite so
|
|
190
|
+
it can't quietly stop being true.
|
|
191
|
+
|
|
192
|
+
**Sources with no clock.** Pass `None` and static tables are skipped, with a
|
|
193
|
+
note saying dependencies on them went untested.
|
|
194
|
+
|
|
195
|
+
**Leakage across rows rather than time** — target encoding fit on the full
|
|
196
|
+
dataset, a scaler fit before the split. Different bug, different tool.
|
|
197
|
+
|
|
198
|
+
## Where this came from
|
|
199
|
+
|
|
200
|
+
The technique is metamorphic testing, which software testing has used for
|
|
201
|
+
decades and research code almost never does. This is an extraction of a check
|
|
202
|
+
built for a quantitative finance pipeline, where the sources are stock prices
|
|
203
|
+
and regulatory filings and the question is whether a factor read an earnings
|
|
204
|
+
figure before it was published.
|
|
205
|
+
|
|
206
|
+
It caught a real one. A factor called `turnover_1m` was classified as
|
|
207
|
+
price-and-volume — it's built from volume, it lives in `price.py`, and every
|
|
208
|
+
human who looked at it filed it under prices. It divides by shares outstanding,
|
|
209
|
+
which comes off a filing. It was the only member of its group that moved when
|
|
210
|
+
the filing calendar shifted, while eleven genuine price factors held at exactly
|
|
211
|
+
zero. That measurement is written up in
|
|
212
|
+
[bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
|
|
213
|
+
|
|
214
|
+
## Install
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install leakprobe # pandas is the only dependency
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Python 3.10+.
|
|
221
|
+
|
|
222
|
+
## Licence
|
|
223
|
+
|
|
224
|
+
MIT.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# leakprobe
|
|
2
|
+
|
|
3
|
+
[](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [](https://pypi.org/project/leakprobe/) [](https://pypi.org/project/leakprobe/)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
**Find features that read data they were never supposed to see.**
|
|
7
|
+
|
|
8
|
+
Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
|
|
9
|
+
something that wasn't knowable yet, nothing throws, no number looks implausible,
|
|
10
|
+
and your model gets better. You find out in production, if you find out at all.
|
|
11
|
+
|
|
12
|
+
`leakprobe` finds it without needing to know the right answer. It needs one
|
|
13
|
+
thing you already know: **a change to your data that your features must be
|
|
14
|
+
invariant to.**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install leakprobe
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import leakprobe as lp
|
|
22
|
+
|
|
23
|
+
report = lp.check(
|
|
24
|
+
compute=build_features, # (sources) -> DataFrame of features
|
|
25
|
+
sources={"events": events, "tickets": tickets},
|
|
26
|
+
timestamps={"events": "occurred_at", "tickets": "resolved_at"},
|
|
27
|
+
declared={
|
|
28
|
+
"total_spend": ["events"],
|
|
29
|
+
"event_count": ["events"],
|
|
30
|
+
"tickets_resolved": ["tickets"],
|
|
31
|
+
"avg_severity": ["tickets"],
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
report.raise_for_leaks() # fails your test suite if anything leaked
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
4 features x 2 sources
|
|
39
|
+
|
|
40
|
+
feature events tickets
|
|
41
|
+
total_spend reads it exactly 0
|
|
42
|
+
event_count reads it exactly 0
|
|
43
|
+
tickets_resolved exactly 0 reads it
|
|
44
|
+
avg_severity exactly 0 bypass?
|
|
45
|
+
|
|
46
|
+
No undeclared dependencies.
|
|
47
|
+
|
|
48
|
+
Declared, but did not respond to the source's clock:
|
|
49
|
+
- avg_severity declares tickets but did not move when its clock did -- it
|
|
50
|
+
reads the source without consulting availability, or the declaration is stale
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That last line is a real bug. `avg_severity` filters tickets on `opened_at`
|
|
54
|
+
instead of `resolved_at`, so tickets that were still open at scoring time leak
|
|
55
|
+
in — exactly the ones that predict churn. Nothing about the code looks wrong.
|
|
56
|
+
|
|
57
|
+
## Try it on real data
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install leakprobe openpyxl
|
|
61
|
+
python examples/online_retail.py
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
|
|
65
|
+
customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
|
|
66
|
+
built as of a cutoff, two of them wrong in the two ways temporal leakage
|
|
67
|
+
actually happens. Neither raises. Both are caught:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
feature orders returns
|
|
71
|
+
total_spend reads it exactly 0
|
|
72
|
+
order_count reads it exactly 0
|
|
73
|
+
recency_days reads it exactly 0
|
|
74
|
+
return_count exactly 0 reads it
|
|
75
|
+
avg_unit_price bypass? exactly 0
|
|
76
|
+
net_spend reads it LEAK
|
|
77
|
+
|
|
78
|
+
1 undeclared dependencies:
|
|
79
|
+
- net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
|
|
80
|
+
|
|
81
|
+
Declared, but did not respond to the source's clock:
|
|
82
|
+
- avg_unit_price declares orders but did not move when its clock did
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`avg_unit_price` is missing one cutoff filter, so it averages invoices that had
|
|
86
|
+
not happened yet. `net_spend` reaches into the returns table without declaring
|
|
87
|
+
it, silently inheriting that table's latency. One dropped subscript and one
|
|
88
|
+
undeclared read -- the two shapes this bug takes in production.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
Four steps, and no ground truth anywhere in them.
|
|
93
|
+
|
|
94
|
+
1. **Move when a source became knowable.** Not its values — only its
|
|
95
|
+
availability timestamp. `delay` pushes it later, which can only ever remove
|
|
96
|
+
information.
|
|
97
|
+
2. **Recompute every feature.**
|
|
98
|
+
3. **Compare, exactly.** A feature that genuinely cannot read that source gets
|
|
99
|
+
the identical input arrays through the identical code and returns bit-identical
|
|
100
|
+
floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
|
|
101
|
+
of a dependency, not a number you have to squint at.
|
|
102
|
+
4. **Check what moved against what you declared.** Anything in one list and not
|
|
103
|
+
the other is the finding.
|
|
104
|
+
|
|
105
|
+
Two kinds of finding:
|
|
106
|
+
|
|
107
|
+
- **`leak`** — moved, but doesn't declare the source. It has a dependency you
|
|
108
|
+
didn't know about. In a temporal pipeline, an unknown dependency on *when*
|
|
109
|
+
data arrived is look-ahead.
|
|
110
|
+
- **`bypass?`** — declares the source but didn't move when that source's clock
|
|
111
|
+
did. It's reaching the data by a path that ignores availability, which is how
|
|
112
|
+
look-ahead usually gets in. Not a failure on its own. Worth reading.
|
|
113
|
+
|
|
114
|
+
Before any of that, `check` runs `compute` twice on untouched inputs and refuses
|
|
115
|
+
to continue if the two runs disagree. A nondeterministic pipeline makes every
|
|
116
|
+
result below it noise, so that's a hard error rather than a warning.
|
|
117
|
+
|
|
118
|
+
## Declaring nothing
|
|
119
|
+
|
|
120
|
+
You don't have to write the `declared` map. Leave it out and every real timing
|
|
121
|
+
dependency is reported:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
report = lp.check(compute, sources, timestamps, declared={})
|
|
125
|
+
for f in report.leaks:
|
|
126
|
+
print(f.feature, "reads", f.source)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
That's the fastest way to answer "what does this pipeline actually depend on"
|
|
130
|
+
for code you inherited, which is usually a shorter list than the author believed.
|
|
131
|
+
|
|
132
|
+
## In CI
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
def test_no_temporal_leakage():
|
|
136
|
+
lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The declaration map becomes the thing code review argues about, which is where
|
|
140
|
+
that argument belongs.
|
|
141
|
+
|
|
142
|
+
## Perturbations
|
|
143
|
+
|
|
144
|
+
| | |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
|
|
147
|
+
| `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
|
|
148
|
+
| `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## What it will not catch
|
|
155
|
+
|
|
156
|
+
**Dependencies that don't flow through a timestamp.** The perturbation moves
|
|
157
|
+
availability, so it reveals as-of joins, merges on a date, and windows anchored
|
|
158
|
+
to one. A feature that reads a source's values with no reference to when they
|
|
159
|
+
arrived is invariant to it and will not be flagged. In practice this costs less
|
|
160
|
+
than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
|
|
161
|
+
catching are the detectable ones. The boundary is asserted in the test suite so
|
|
162
|
+
it can't quietly stop being true.
|
|
163
|
+
|
|
164
|
+
**Sources with no clock.** Pass `None` and static tables are skipped, with a
|
|
165
|
+
note saying dependencies on them went untested.
|
|
166
|
+
|
|
167
|
+
**Leakage across rows rather than time** — target encoding fit on the full
|
|
168
|
+
dataset, a scaler fit before the split. Different bug, different tool.
|
|
169
|
+
|
|
170
|
+
## Where this came from
|
|
171
|
+
|
|
172
|
+
The technique is metamorphic testing, which software testing has used for
|
|
173
|
+
decades and research code almost never does. This is an extraction of a check
|
|
174
|
+
built for a quantitative finance pipeline, where the sources are stock prices
|
|
175
|
+
and regulatory filings and the question is whether a factor read an earnings
|
|
176
|
+
figure before it was published.
|
|
177
|
+
|
|
178
|
+
It caught a real one. A factor called `turnover_1m` was classified as
|
|
179
|
+
price-and-volume — it's built from volume, it lives in `price.py`, and every
|
|
180
|
+
human who looked at it filed it under prices. It divides by shares outstanding,
|
|
181
|
+
which comes off a filing. It was the only member of its group that moved when
|
|
182
|
+
the filing calendar shifted, while eleven genuine price factors held at exactly
|
|
183
|
+
zero. That measurement is written up in
|
|
184
|
+
[bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
|
|
185
|
+
|
|
186
|
+
## Install
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pip install leakprobe # pandas is the only dependency
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Python 3.10+.
|
|
193
|
+
|
|
194
|
+
## Licence
|
|
195
|
+
|
|
196
|
+
MIT.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "leakprobe"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Raunak Sood" }]
|
|
13
|
+
keywords = ["leakage", "data-leakage", "machine-learning", "testing", "time-series", "point-in-time", "feature-engineering"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["pandas>=2.0"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=7.4", "numpy>=1.26"]
|
|
27
|
+
examples = ["numpy>=1.26", "openpyxl>=3.1"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/quantraunak/leakprobe"
|
|
31
|
+
Issues = "https://github.com/quantraunak/leakprobe/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""leakprobe -- find features that read data they were never supposed to see.
|
|
2
|
+
|
|
3
|
+
import leakprobe as lc
|
|
4
|
+
|
|
5
|
+
report = lc.check(
|
|
6
|
+
compute=build_features,
|
|
7
|
+
sources={"events": events, "profiles": profiles},
|
|
8
|
+
timestamps={"events": "occurred_at", "profiles": "updated_at"},
|
|
9
|
+
declared={"rolling_7d_spend": ["events"]},
|
|
10
|
+
)
|
|
11
|
+
report.raise_for_leaks()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .core import check
|
|
15
|
+
from .perturb import advance, delay, use_column
|
|
16
|
+
from .report import Finding, Report
|
|
17
|
+
|
|
18
|
+
__all__ = ["check", "delay", "advance", "use_column", "Report", "Finding"]
|
|
19
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Find features that read a source they never declared.
|
|
2
|
+
|
|
3
|
+
The test needs no ground truth. It needs one change the answer must be
|
|
4
|
+
invariant to: move when a source became knowable, recompute, and read the list
|
|
5
|
+
of features that flinched.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from .perturb import delay
|
|
15
|
+
from .report import BYPASS, LEAK, OK, Finding, Report
|
|
16
|
+
|
|
17
|
+
__all__ = ["check"]
|
|
18
|
+
|
|
19
|
+
Sources = Mapping[str, pd.DataFrame]
|
|
20
|
+
Compute = Callable[[Sources], pd.DataFrame]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_frame(result: object, label: str) -> pd.DataFrame:
|
|
24
|
+
if isinstance(result, pd.Series):
|
|
25
|
+
return result.to_frame()
|
|
26
|
+
if isinstance(result, pd.DataFrame):
|
|
27
|
+
return result
|
|
28
|
+
raise TypeError(f"{label} must return a DataFrame or Series, got {type(result).__name__}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _changed(left: pd.Series, right: pd.Series, tolerance: float) -> tuple[bool, float]:
|
|
32
|
+
"""Did this column move? NaN in the same place counts as unchanged."""
|
|
33
|
+
both_null = left.isna() & right.isna()
|
|
34
|
+
if (left.isna() != right.isna()).any():
|
|
35
|
+
return True, float("inf")
|
|
36
|
+
if pd.api.types.is_numeric_dtype(left) and pd.api.types.is_numeric_dtype(right):
|
|
37
|
+
gap = (left - right).abs()
|
|
38
|
+
gap = gap[~both_null]
|
|
39
|
+
worst = float(gap.max()) if len(gap) else 0.0
|
|
40
|
+
return worst > tolerance, worst
|
|
41
|
+
unequal = (left != right) & ~both_null
|
|
42
|
+
return bool(unequal.any()), float(unequal.sum())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(
|
|
46
|
+
compute: Compute,
|
|
47
|
+
sources: Sources,
|
|
48
|
+
timestamps: Mapping[str, str],
|
|
49
|
+
declared: Mapping[str, Sequence[str]],
|
|
50
|
+
*,
|
|
51
|
+
by: pd.Timedelta = pd.Timedelta(days=30),
|
|
52
|
+
tolerance: float = 0.0,
|
|
53
|
+
perturb: Callable[[pd.DataFrame, str, pd.Timedelta], pd.DataFrame] = delay,
|
|
54
|
+
) -> Report:
|
|
55
|
+
"""Perturb each source's availability in turn; report features that moved.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
compute: builds your features. Takes the sources dict, returns a frame
|
|
59
|
+
whose columns are features. Must be deterministic -- this is checked.
|
|
60
|
+
sources: name -> raw table. Each carries an availability timestamp.
|
|
61
|
+
timestamps: source name -> the column holding that timestamp, or None for
|
|
62
|
+
a static table with no clock, which is skipped.
|
|
63
|
+
declared: feature name -> the sources it is supposed to read. A feature
|
|
64
|
+
absent from this mapping is treated as declaring nothing, which is
|
|
65
|
+
the strictest reading and usually what you want for a new pipeline.
|
|
66
|
+
by: how far to move availability. Large enough to bite, small enough to
|
|
67
|
+
leave rows in the window.
|
|
68
|
+
tolerance: treat changes at or below this as no change. Leave at 0.0
|
|
69
|
+
unless your pipeline has genuine floating-point nondeterminism, and
|
|
70
|
+
prefer to fix that instead.
|
|
71
|
+
perturb: the perturbation. `delay` removes information and is the safe
|
|
72
|
+
default; see `leakprobe.perturb` for others.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A Report. `report.leaks` is the list of undeclared dependencies, and
|
|
76
|
+
`report.raise_for_leaks()` turns them into a test failure.
|
|
77
|
+
"""
|
|
78
|
+
absent = set(sources) - set(timestamps)
|
|
79
|
+
if absent:
|
|
80
|
+
raise KeyError(
|
|
81
|
+
f"No availability column given for source(s): {sorted(absent)}. "
|
|
82
|
+
"Pass None for a static table that has no clock."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
unknown = {s for deps in declared.values() for s in deps} - set(sources)
|
|
86
|
+
if unknown:
|
|
87
|
+
raise KeyError(f"declared refers to unknown source(s): {sorted(unknown)}")
|
|
88
|
+
|
|
89
|
+
baseline = _as_frame(compute(sources), "compute")
|
|
90
|
+
control = _as_frame(compute(sources), "compute")
|
|
91
|
+
|
|
92
|
+
notes: list[str] = []
|
|
93
|
+
if list(baseline.columns) != list(control.columns):
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
"compute() returned different columns on two identical calls. The test "
|
|
96
|
+
"cannot separate a leak from nondeterminism until that is fixed."
|
|
97
|
+
)
|
|
98
|
+
unstable = [c for c in baseline.columns if _changed(baseline[c], control[c], tolerance)[0]]
|
|
99
|
+
if unstable:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"compute() is not deterministic: "
|
|
102
|
+
+ ", ".join(unstable[:5])
|
|
103
|
+
+ (" ..." if len(unstable) > 5 else "")
|
|
104
|
+
+ ". Seed it, or every result below is noise."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
features = [str(c) for c in baseline.columns]
|
|
108
|
+
findings: list[Finding] = []
|
|
109
|
+
|
|
110
|
+
for source in sources:
|
|
111
|
+
if timestamps[source] is None:
|
|
112
|
+
notes.append(
|
|
113
|
+
f"note: {source!r} has no availability column, so nothing about it was "
|
|
114
|
+
f"perturbed. Dependencies on it are untested."
|
|
115
|
+
)
|
|
116
|
+
continue
|
|
117
|
+
moved_frame = _as_frame(
|
|
118
|
+
compute({**sources, source: perturb(sources[source], timestamps[source], by)}),
|
|
119
|
+
"compute",
|
|
120
|
+
)
|
|
121
|
+
if list(moved_frame.columns) != list(baseline.columns):
|
|
122
|
+
notes.append(
|
|
123
|
+
f"note: perturbing {source!r} changed the feature set itself, which is "
|
|
124
|
+
f"a stronger dependency than this test is designed to describe."
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
for column, feature in zip(baseline.columns, features):
|
|
129
|
+
moved, worst = _changed(baseline[column], moved_frame[column], tolerance)
|
|
130
|
+
is_declared = source in declared.get(feature, ())
|
|
131
|
+
if moved and not is_declared:
|
|
132
|
+
kind = LEAK
|
|
133
|
+
elif is_declared and not moved:
|
|
134
|
+
kind = BYPASS
|
|
135
|
+
else:
|
|
136
|
+
kind = OK
|
|
137
|
+
findings.append(
|
|
138
|
+
Finding(
|
|
139
|
+
feature=feature,
|
|
140
|
+
source=source,
|
|
141
|
+
kind=kind,
|
|
142
|
+
declared=is_declared,
|
|
143
|
+
moved=moved,
|
|
144
|
+
max_abs_change=worst,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return Report(
|
|
149
|
+
findings=findings, features=features, sources=list(sources), notes=notes
|
|
150
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Ways to move when a source becomes knowable.
|
|
2
|
+
|
|
3
|
+
A perturbation changes *availability*, never values. That distinction is what
|
|
4
|
+
makes the test sound: if a feature's number changes after a perturbation, the
|
|
5
|
+
feature read something whose timing moved, and timing is the only thing that
|
|
6
|
+
moved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
__all__ = ["delay", "advance", "use_column"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check(frame: pd.DataFrame, column: str) -> None:
|
|
17
|
+
if column not in frame.columns:
|
|
18
|
+
raise KeyError(
|
|
19
|
+
f"No availability column {column!r}. Columns present: {list(frame.columns)}"
|
|
20
|
+
)
|
|
21
|
+
if not pd.api.types.is_datetime64_any_dtype(frame[column]):
|
|
22
|
+
raise TypeError(
|
|
23
|
+
f"Availability column {column!r} is {frame[column].dtype}, not a datetime. "
|
|
24
|
+
"Parse it with pd.to_datetime first."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def delay(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
|
|
29
|
+
"""The same rows, knowable `by` later.
|
|
30
|
+
|
|
31
|
+
The safe default. Delaying a source can only remove information, so a
|
|
32
|
+
feature that legitimately reads it will change and one that does not cannot.
|
|
33
|
+
"""
|
|
34
|
+
_check(frame, column)
|
|
35
|
+
out = frame.copy()
|
|
36
|
+
out[column] = out[column] + by
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def advance(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
|
|
41
|
+
"""The same rows, knowable `by` earlier -- look-ahead, injected on purpose.
|
|
42
|
+
|
|
43
|
+
Use when you want to measure what a leak would be *worth* rather than
|
|
44
|
+
whether one exists.
|
|
45
|
+
"""
|
|
46
|
+
_check(frame, column)
|
|
47
|
+
out = frame.copy()
|
|
48
|
+
out[column] = out[column] - by
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def use_column(frame: pd.DataFrame, column: str, source: str) -> pd.DataFrame:
|
|
53
|
+
"""Availability taken from another column.
|
|
54
|
+
|
|
55
|
+
Models the specific mistake of treating a record as knowable when the period
|
|
56
|
+
it describes ended, rather than when it was published: `use_column(facts,
|
|
57
|
+
"available_at", "period_end")`.
|
|
58
|
+
"""
|
|
59
|
+
_check(frame, column)
|
|
60
|
+
if source not in frame.columns:
|
|
61
|
+
raise KeyError(f"No column {source!r} to take availability from.")
|
|
62
|
+
out = frame.copy()
|
|
63
|
+
out[column] = out[source]
|
|
64
|
+
return out
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The verdict, and how it prints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
__all__ = ["Finding", "Report"]
|
|
8
|
+
|
|
9
|
+
LEAK = "leak"
|
|
10
|
+
BYPASS = "bypass"
|
|
11
|
+
OK = "ok"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Finding:
|
|
16
|
+
"""One (feature, source) pair, and what the perturbation did to it."""
|
|
17
|
+
|
|
18
|
+
feature: str
|
|
19
|
+
source: str
|
|
20
|
+
kind: str
|
|
21
|
+
declared: bool
|
|
22
|
+
moved: bool
|
|
23
|
+
max_abs_change: float
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
if self.kind == LEAK:
|
|
27
|
+
return (
|
|
28
|
+
f"{self.feature} moved when {self.source} was perturbed, and does not "
|
|
29
|
+
f"declare it (max change {self.max_abs_change:.3g})"
|
|
30
|
+
)
|
|
31
|
+
if self.kind == BYPASS:
|
|
32
|
+
return (
|
|
33
|
+
f"{self.feature} declares {self.source} but did not move when its clock "
|
|
34
|
+
f"did -- it reads the source without consulting availability, or the "
|
|
35
|
+
f"declaration is stale"
|
|
36
|
+
)
|
|
37
|
+
return f"{self.feature} / {self.source}: as declared"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Report:
|
|
42
|
+
findings: list[Finding] = field(default_factory=list)
|
|
43
|
+
features: list[str] = field(default_factory=list)
|
|
44
|
+
sources: list[str] = field(default_factory=list)
|
|
45
|
+
notes: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def leaks(self) -> list[Finding]:
|
|
49
|
+
"""Undeclared dependencies. These are the bugs."""
|
|
50
|
+
return [f for f in self.findings if f.kind == LEAK]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def bypassed(self) -> list[Finding]:
|
|
54
|
+
"""Declared dependencies that did not respond to their own clock.
|
|
55
|
+
|
|
56
|
+
Either the declaration is stale, or the feature reaches the source by a
|
|
57
|
+
path that ignores its availability column -- which is how look-ahead
|
|
58
|
+
usually gets in. Not a failure on its own; worth reading.
|
|
59
|
+
"""
|
|
60
|
+
return [f for f in self.findings if f.kind == BYPASS]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def clean(self) -> bool:
|
|
64
|
+
return not self.leaks
|
|
65
|
+
|
|
66
|
+
def raise_for_leaks(self) -> None:
|
|
67
|
+
"""For use in a test suite: turn a leak into a failure."""
|
|
68
|
+
if self.leaks:
|
|
69
|
+
listed = "\n".join(f" - {f}" for f in self.leaks)
|
|
70
|
+
raise AssertionError(f"{len(self.leaks)} undeclared dependencies:\n{listed}")
|
|
71
|
+
|
|
72
|
+
def __str__(self) -> str:
|
|
73
|
+
width = max((len(f) for f in self.features), default=7)
|
|
74
|
+
lines = [
|
|
75
|
+
f"{len(self.features)} features x {len(self.sources)} sources",
|
|
76
|
+
"",
|
|
77
|
+
f"{'feature':<{width}} " + " ".join(f"{s:>12}" for s in self.sources),
|
|
78
|
+
]
|
|
79
|
+
by_pair = {(f.feature, f.source): f for f in self.findings}
|
|
80
|
+
for feature in self.features:
|
|
81
|
+
cells = []
|
|
82
|
+
for source in self.sources:
|
|
83
|
+
found = by_pair.get((feature, source))
|
|
84
|
+
if found is None:
|
|
85
|
+
cells.append(f"{'-':>12}")
|
|
86
|
+
elif found.kind == LEAK:
|
|
87
|
+
cells.append(f"{'LEAK':>12}")
|
|
88
|
+
elif found.kind == BYPASS:
|
|
89
|
+
cells.append(f"{'bypass?':>12}")
|
|
90
|
+
elif found.declared:
|
|
91
|
+
cells.append(f"{'reads it':>12}")
|
|
92
|
+
else:
|
|
93
|
+
cells.append(f"{'exactly 0':>12}")
|
|
94
|
+
lines.append(f"{feature:<{width}} " + " ".join(cells))
|
|
95
|
+
|
|
96
|
+
lines.append("")
|
|
97
|
+
if self.leaks:
|
|
98
|
+
lines.append(f"{len(self.leaks)} undeclared dependencies:")
|
|
99
|
+
lines += [f" - {f}" for f in self.leaks]
|
|
100
|
+
else:
|
|
101
|
+
lines.append("No undeclared dependencies.")
|
|
102
|
+
if self.bypassed:
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append("Declared, but did not respond to the source's clock:")
|
|
105
|
+
lines += [f" - {f}" for f in self.bypassed]
|
|
106
|
+
if self.notes:
|
|
107
|
+
lines.append("")
|
|
108
|
+
lines += self.notes
|
|
109
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leakprobe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing.
|
|
5
|
+
Author: Raunak Sood
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/quantraunak/leakprobe
|
|
8
|
+
Project-URL: Issues, https://github.com/quantraunak/leakprobe/issues
|
|
9
|
+
Keywords: leakage,data-leakage,machine-learning,testing,time-series,point-in-time,feature-engineering
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
23
|
+
Requires-Dist: numpy>=1.26; extra == "dev"
|
|
24
|
+
Provides-Extra: examples
|
|
25
|
+
Requires-Dist: numpy>=1.26; extra == "examples"
|
|
26
|
+
Requires-Dist: openpyxl>=3.1; extra == "examples"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# leakprobe
|
|
30
|
+
|
|
31
|
+
[](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [](https://pypi.org/project/leakprobe/) [](https://pypi.org/project/leakprobe/)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
**Find features that read data they were never supposed to see.**
|
|
35
|
+
|
|
36
|
+
Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
|
|
37
|
+
something that wasn't knowable yet, nothing throws, no number looks implausible,
|
|
38
|
+
and your model gets better. You find out in production, if you find out at all.
|
|
39
|
+
|
|
40
|
+
`leakprobe` finds it without needing to know the right answer. It needs one
|
|
41
|
+
thing you already know: **a change to your data that your features must be
|
|
42
|
+
invariant to.**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install leakprobe
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import leakprobe as lp
|
|
50
|
+
|
|
51
|
+
report = lp.check(
|
|
52
|
+
compute=build_features, # (sources) -> DataFrame of features
|
|
53
|
+
sources={"events": events, "tickets": tickets},
|
|
54
|
+
timestamps={"events": "occurred_at", "tickets": "resolved_at"},
|
|
55
|
+
declared={
|
|
56
|
+
"total_spend": ["events"],
|
|
57
|
+
"event_count": ["events"],
|
|
58
|
+
"tickets_resolved": ["tickets"],
|
|
59
|
+
"avg_severity": ["tickets"],
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
report.raise_for_leaks() # fails your test suite if anything leaked
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
4 features x 2 sources
|
|
67
|
+
|
|
68
|
+
feature events tickets
|
|
69
|
+
total_spend reads it exactly 0
|
|
70
|
+
event_count reads it exactly 0
|
|
71
|
+
tickets_resolved exactly 0 reads it
|
|
72
|
+
avg_severity exactly 0 bypass?
|
|
73
|
+
|
|
74
|
+
No undeclared dependencies.
|
|
75
|
+
|
|
76
|
+
Declared, but did not respond to the source's clock:
|
|
77
|
+
- avg_severity declares tickets but did not move when its clock did -- it
|
|
78
|
+
reads the source without consulting availability, or the declaration is stale
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That last line is a real bug. `avg_severity` filters tickets on `opened_at`
|
|
82
|
+
instead of `resolved_at`, so tickets that were still open at scoring time leak
|
|
83
|
+
in — exactly the ones that predict churn. Nothing about the code looks wrong.
|
|
84
|
+
|
|
85
|
+
## Try it on real data
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install leakprobe openpyxl
|
|
89
|
+
python examples/online_retail.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
|
|
93
|
+
customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
|
|
94
|
+
built as of a cutoff, two of them wrong in the two ways temporal leakage
|
|
95
|
+
actually happens. Neither raises. Both are caught:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
feature orders returns
|
|
99
|
+
total_spend reads it exactly 0
|
|
100
|
+
order_count reads it exactly 0
|
|
101
|
+
recency_days reads it exactly 0
|
|
102
|
+
return_count exactly 0 reads it
|
|
103
|
+
avg_unit_price bypass? exactly 0
|
|
104
|
+
net_spend reads it LEAK
|
|
105
|
+
|
|
106
|
+
1 undeclared dependencies:
|
|
107
|
+
- net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
|
|
108
|
+
|
|
109
|
+
Declared, but did not respond to the source's clock:
|
|
110
|
+
- avg_unit_price declares orders but did not move when its clock did
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`avg_unit_price` is missing one cutoff filter, so it averages invoices that had
|
|
114
|
+
not happened yet. `net_spend` reaches into the returns table without declaring
|
|
115
|
+
it, silently inheriting that table's latency. One dropped subscript and one
|
|
116
|
+
undeclared read -- the two shapes this bug takes in production.
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
Four steps, and no ground truth anywhere in them.
|
|
121
|
+
|
|
122
|
+
1. **Move when a source became knowable.** Not its values — only its
|
|
123
|
+
availability timestamp. `delay` pushes it later, which can only ever remove
|
|
124
|
+
information.
|
|
125
|
+
2. **Recompute every feature.**
|
|
126
|
+
3. **Compare, exactly.** A feature that genuinely cannot read that source gets
|
|
127
|
+
the identical input arrays through the identical code and returns bit-identical
|
|
128
|
+
floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
|
|
129
|
+
of a dependency, not a number you have to squint at.
|
|
130
|
+
4. **Check what moved against what you declared.** Anything in one list and not
|
|
131
|
+
the other is the finding.
|
|
132
|
+
|
|
133
|
+
Two kinds of finding:
|
|
134
|
+
|
|
135
|
+
- **`leak`** — moved, but doesn't declare the source. It has a dependency you
|
|
136
|
+
didn't know about. In a temporal pipeline, an unknown dependency on *when*
|
|
137
|
+
data arrived is look-ahead.
|
|
138
|
+
- **`bypass?`** — declares the source but didn't move when that source's clock
|
|
139
|
+
did. It's reaching the data by a path that ignores availability, which is how
|
|
140
|
+
look-ahead usually gets in. Not a failure on its own. Worth reading.
|
|
141
|
+
|
|
142
|
+
Before any of that, `check` runs `compute` twice on untouched inputs and refuses
|
|
143
|
+
to continue if the two runs disagree. A nondeterministic pipeline makes every
|
|
144
|
+
result below it noise, so that's a hard error rather than a warning.
|
|
145
|
+
|
|
146
|
+
## Declaring nothing
|
|
147
|
+
|
|
148
|
+
You don't have to write the `declared` map. Leave it out and every real timing
|
|
149
|
+
dependency is reported:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
report = lp.check(compute, sources, timestamps, declared={})
|
|
153
|
+
for f in report.leaks:
|
|
154
|
+
print(f.feature, "reads", f.source)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
That's the fastest way to answer "what does this pipeline actually depend on"
|
|
158
|
+
for code you inherited, which is usually a shorter list than the author believed.
|
|
159
|
+
|
|
160
|
+
## In CI
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
def test_no_temporal_leakage():
|
|
164
|
+
lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The declaration map becomes the thing code review argues about, which is where
|
|
168
|
+
that argument belongs.
|
|
169
|
+
|
|
170
|
+
## Perturbations
|
|
171
|
+
|
|
172
|
+
| | |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
|
|
175
|
+
| `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
|
|
176
|
+
| `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## What it will not catch
|
|
183
|
+
|
|
184
|
+
**Dependencies that don't flow through a timestamp.** The perturbation moves
|
|
185
|
+
availability, so it reveals as-of joins, merges on a date, and windows anchored
|
|
186
|
+
to one. A feature that reads a source's values with no reference to when they
|
|
187
|
+
arrived is invariant to it and will not be flagged. In practice this costs less
|
|
188
|
+
than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
|
|
189
|
+
catching are the detectable ones. The boundary is asserted in the test suite so
|
|
190
|
+
it can't quietly stop being true.
|
|
191
|
+
|
|
192
|
+
**Sources with no clock.** Pass `None` and static tables are skipped, with a
|
|
193
|
+
note saying dependencies on them went untested.
|
|
194
|
+
|
|
195
|
+
**Leakage across rows rather than time** — target encoding fit on the full
|
|
196
|
+
dataset, a scaler fit before the split. Different bug, different tool.
|
|
197
|
+
|
|
198
|
+
## Where this came from
|
|
199
|
+
|
|
200
|
+
The technique is metamorphic testing, which software testing has used for
|
|
201
|
+
decades and research code almost never does. This is an extraction of a check
|
|
202
|
+
built for a quantitative finance pipeline, where the sources are stock prices
|
|
203
|
+
and regulatory filings and the question is whether a factor read an earnings
|
|
204
|
+
figure before it was published.
|
|
205
|
+
|
|
206
|
+
It caught a real one. A factor called `turnover_1m` was classified as
|
|
207
|
+
price-and-volume — it's built from volume, it lives in `price.py`, and every
|
|
208
|
+
human who looked at it filed it under prices. It divides by shares outstanding,
|
|
209
|
+
which comes off a filing. It was the only member of its group that moved when
|
|
210
|
+
the filing calendar shifted, while eleven genuine price factors held at exactly
|
|
211
|
+
zero. That measurement is written up in
|
|
212
|
+
[bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
|
|
213
|
+
|
|
214
|
+
## Install
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install leakprobe # pandas is the only dependency
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Python 3.10+.
|
|
221
|
+
|
|
222
|
+
## Licence
|
|
223
|
+
|
|
224
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/leakprobe/__init__.py
|
|
5
|
+
src/leakprobe/core.py
|
|
6
|
+
src/leakprobe/perturb.py
|
|
7
|
+
src/leakprobe/report.py
|
|
8
|
+
src/leakprobe.egg-info/PKG-INFO
|
|
9
|
+
src/leakprobe.egg-info/SOURCES.txt
|
|
10
|
+
src/leakprobe.egg-info/dependency_links.txt
|
|
11
|
+
src/leakprobe.egg-info/requires.txt
|
|
12
|
+
src/leakprobe.egg-info/top_level.txt
|
|
13
|
+
tests/test_core.py
|
|
14
|
+
tests/test_perturb.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
leakprobe
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The claims the tool rests on, each broken on purpose."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import leakprobe as lc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def sources():
|
|
12
|
+
days = pd.date_range("2024-01-01", periods=120, freq="D")
|
|
13
|
+
rng = np.random.default_rng(0)
|
|
14
|
+
prices = pd.DataFrame(
|
|
15
|
+
{
|
|
16
|
+
"available_at": days,
|
|
17
|
+
"price": 100 + rng.normal(0, 1, len(days)).cumsum(),
|
|
18
|
+
"volume": rng.integers(1_000, 5_000, len(days)),
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
quarters = pd.to_datetime(["2023-12-31", "2024-03-31"])
|
|
22
|
+
filings = pd.DataFrame(
|
|
23
|
+
{
|
|
24
|
+
"period_end": quarters,
|
|
25
|
+
"available_at": quarters + pd.Timedelta(days=34),
|
|
26
|
+
"earnings": [4.0, 5.0],
|
|
27
|
+
"shares": [1_000.0, 1_100.0],
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
return {"prices": prices, "filings": filings}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _as_of(filings, dates, column):
|
|
34
|
+
"""Latest value knowable on each date. The whole point is that this respects
|
|
35
|
+
`available_at`, so moving it moves the answer."""
|
|
36
|
+
ordered = filings.sort_values("available_at")
|
|
37
|
+
idx = np.searchsorted(ordered["available_at"].to_numpy(), dates.to_numpy(), side="right") - 1
|
|
38
|
+
out = np.where(idx >= 0, ordered[column].to_numpy()[np.clip(idx, 0, None)], np.nan)
|
|
39
|
+
return pd.Series(out, index=range(len(dates)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build(sources):
|
|
43
|
+
prices, filings = sources["prices"], sources["filings"]
|
|
44
|
+
dates = prices["available_at"]
|
|
45
|
+
return pd.DataFrame(
|
|
46
|
+
{
|
|
47
|
+
"momentum": prices["price"].pct_change(20),
|
|
48
|
+
"earnings_yield": _as_of(filings, dates, "earnings") / prices["price"],
|
|
49
|
+
# Looks like a price feature. Divides by a filed number.
|
|
50
|
+
"turnover": prices["volume"] / _as_of(filings, dates, "shares"),
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_price_only_feature_is_exactly_unchanged(sources):
|
|
56
|
+
report = lc.check(
|
|
57
|
+
build, sources,
|
|
58
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
59
|
+
declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
|
|
60
|
+
"turnover": ["prices", "filings"]},
|
|
61
|
+
)
|
|
62
|
+
hit = next(f for f in report.findings if f.feature == "momentum" and f.source == "filings")
|
|
63
|
+
assert not hit.moved
|
|
64
|
+
assert hit.max_abs_change == 0.0, "not approximately zero -- exactly zero"
|
|
65
|
+
assert report.clean
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_undeclared_filing_dependency_is_caught(sources):
|
|
69
|
+
"""turnover divides volume by shares outstanding, so it reads a filing.
|
|
70
|
+
Declared as price-only, which is the mistake this tool exists to catch."""
|
|
71
|
+
report = lc.check(
|
|
72
|
+
build, sources,
|
|
73
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
74
|
+
declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
|
|
75
|
+
"turnover": ["prices"]},
|
|
76
|
+
)
|
|
77
|
+
assert [f.feature for f in report.leaks] == ["turnover"]
|
|
78
|
+
assert report.leaks[0].source == "filings"
|
|
79
|
+
assert not report.clean
|
|
80
|
+
with pytest.raises(AssertionError, match="turnover"):
|
|
81
|
+
report.raise_for_leaks()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_declaring_nothing_flags_every_timing_dependency(sources):
|
|
85
|
+
"""Everything that consults a timestamp is caught. `momentum` is not, and
|
|
86
|
+
that is the method's boundary rather than a miss: it reads price *values*
|
|
87
|
+
and never asks when they arrived, so shifting availability uniformly cannot
|
|
88
|
+
move it. See test_values_read_without_consulting_time_are_invisible."""
|
|
89
|
+
report = lc.check(
|
|
90
|
+
build, sources,
|
|
91
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
92
|
+
declared={},
|
|
93
|
+
)
|
|
94
|
+
assert {f.feature for f in report.leaks} == {"earnings_yield", "turnover"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_declared_but_clock_insensitive_is_reported_separately(sources):
|
|
98
|
+
report = lc.check(
|
|
99
|
+
build, sources,
|
|
100
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
101
|
+
declared={"momentum": ["prices", "filings"], "earnings_yield": ["filings", "prices"],
|
|
102
|
+
"turnover": ["prices", "filings"]},
|
|
103
|
+
)
|
|
104
|
+
assert set((f.feature, f.source) for f in report.bypassed) == {
|
|
105
|
+
("momentum", "filings"),
|
|
106
|
+
("momentum", "prices"),
|
|
107
|
+
}
|
|
108
|
+
assert report.clean, "a stale declaration is not a leak"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_nondeterministic_compute_is_refused(sources):
|
|
112
|
+
def unstable(src):
|
|
113
|
+
return pd.DataFrame({"x": np.random.default_rng().normal(size=len(src["prices"]))})
|
|
114
|
+
|
|
115
|
+
with pytest.raises(RuntimeError, match="not deterministic"):
|
|
116
|
+
lc.check(unstable, sources,
|
|
117
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
118
|
+
declared={})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_unknown_source_in_declared_is_refused(sources):
|
|
122
|
+
with pytest.raises(KeyError, match="unknown source"):
|
|
123
|
+
lc.check(build, sources,
|
|
124
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
125
|
+
declared={"momentum": ["typo_source"]})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_missing_timestamp_column_is_refused(sources):
|
|
129
|
+
with pytest.raises(KeyError, match="availability column"):
|
|
130
|
+
lc.check(build, sources, timestamps={"prices": "available_at"}, declared={})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_nan_in_the_same_place_is_not_a_change(sources):
|
|
134
|
+
"""A feature that is NaN for its first 20 rows in both runs has not moved."""
|
|
135
|
+
report = lc.check(
|
|
136
|
+
build, sources,
|
|
137
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
138
|
+
declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
|
|
139
|
+
"turnover": ["prices", "filings"]},
|
|
140
|
+
)
|
|
141
|
+
assert build(sources)["momentum"].isna().sum() > 0
|
|
142
|
+
assert report.clean
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_values_read_without_consulting_time_are_invisible(sources):
|
|
146
|
+
"""The limitation, asserted so it cannot quietly stop being true.
|
|
147
|
+
|
|
148
|
+
The perturbation moves availability, so it can only reveal dependencies that
|
|
149
|
+
flow through a timestamp -- an as-of join, a merge on a date, a window
|
|
150
|
+
anchored to one. A feature that reads a source's values directly, with no
|
|
151
|
+
reference to when they arrived, is invariant to it and will not be flagged.
|
|
152
|
+
|
|
153
|
+
That boundary is not much of a loss in practice: look-ahead is by definition
|
|
154
|
+
a dependency on timing, so the leaks worth catching are the detectable ones.
|
|
155
|
+
"""
|
|
156
|
+
def timeless(src):
|
|
157
|
+
# Reads filings values, never their availability. Undetectable here.
|
|
158
|
+
return pd.DataFrame({"mean_earnings": [src["filings"]["earnings"].mean()]})
|
|
159
|
+
|
|
160
|
+
report = lc.check(
|
|
161
|
+
timeless, sources,
|
|
162
|
+
timestamps={"prices": "available_at", "filings": "available_at"},
|
|
163
|
+
declared={},
|
|
164
|
+
)
|
|
165
|
+
assert report.clean
|
|
166
|
+
assert report.leaks == []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_source_without_a_clock_is_skipped_and_said_so(sources):
|
|
170
|
+
static = pd.DataFrame({"region": ["us", "eu"]})
|
|
171
|
+
report = lc.check(
|
|
172
|
+
lambda src: pd.DataFrame({"x": [len(src["static"])]}),
|
|
173
|
+
{**sources, "static": static},
|
|
174
|
+
timestamps={"prices": "available_at", "filings": "available_at", "static": None},
|
|
175
|
+
declared={},
|
|
176
|
+
)
|
|
177
|
+
assert any("no availability column" in n for n in report.notes)
|
|
178
|
+
assert all(f.source != "static" for f in report.findings)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from leakprobe import advance, delay, use_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def facts():
|
|
9
|
+
ends = pd.to_datetime(["2024-03-31", "2024-06-30"])
|
|
10
|
+
return pd.DataFrame({"period_end": ends, "available_at": ends + pd.Timedelta(days=34)})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_delay_moves_availability_only(facts):
|
|
14
|
+
out = delay(facts, "available_at", pd.Timedelta(days=30))
|
|
15
|
+
assert (out["available_at"] - facts["available_at"] == pd.Timedelta(days=30)).all()
|
|
16
|
+
pd.testing.assert_series_equal(out["period_end"], facts["period_end"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_advance_is_the_inverse(facts):
|
|
20
|
+
by = pd.Timedelta(days=7)
|
|
21
|
+
pd.testing.assert_frame_equal(advance(delay(facts, "available_at", by), "available_at", by), facts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_use_column_models_the_period_end_join(facts):
|
|
25
|
+
out = use_column(facts, "available_at", "period_end")
|
|
26
|
+
pd.testing.assert_series_equal(out["available_at"], facts["period_end"], check_names=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_input_is_never_mutated(facts):
|
|
30
|
+
before = facts.copy()
|
|
31
|
+
delay(facts, "available_at", pd.Timedelta(days=1))
|
|
32
|
+
pd.testing.assert_frame_equal(facts, before)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_non_datetime_column_is_refused(facts):
|
|
36
|
+
bad = facts.assign(available_at=[1, 2])
|
|
37
|
+
with pytest.raises(TypeError, match="not a datetime"):
|
|
38
|
+
delay(bad, "available_at", pd.Timedelta(days=1))
|