pycheck-neuron 1.0.0__py3-none-any.whl
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.
- pycheck/__init__.py +10 -0
- pycheck/__main__.py +3 -0
- pycheck/analyzer.py +198 -0
- pycheck/cli.py +191 -0
- pycheck/flow.py +265 -0
- pycheck/rules.py +938 -0
- pycheck_neuron-1.0.0.dist-info/METADATA +296 -0
- pycheck_neuron-1.0.0.dist-info/RECORD +12 -0
- pycheck_neuron-1.0.0.dist-info/WHEEL +5 -0
- pycheck_neuron-1.0.0.dist-info/entry_points.txt +2 -0
- pycheck_neuron-1.0.0.dist-info/licenses/LICENSE +21 -0
- pycheck_neuron-1.0.0.dist-info/top_level.txt +1 -0
pycheck/rules.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCheck Rule Registry — 40+ ML Safety Rules
|
|
3
|
+
Each rule is a self-contained checker with code, severity, and AST visitor logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional, List, Dict, Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Diagnostic:
|
|
13
|
+
line: int
|
|
14
|
+
col: int
|
|
15
|
+
severity: str # "error" | "warning" | "info"
|
|
16
|
+
code: str # "T001", "C001", etc.
|
|
17
|
+
message: str
|
|
18
|
+
help: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self):
|
|
21
|
+
d = {"line": self.line, "col": self.col, "severity": self.severity,
|
|
22
|
+
"code": self.code, "message": self.message}
|
|
23
|
+
if self.help:
|
|
24
|
+
d["help"] = self.help
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Rule:
|
|
29
|
+
"""Base class for all PyCheck rules."""
|
|
30
|
+
code: str = ""
|
|
31
|
+
name: str = ""
|
|
32
|
+
severity: str = "warning"
|
|
33
|
+
category: str = ""
|
|
34
|
+
|
|
35
|
+
def check_node(self, node: ast.AST, context: 'AnalysisContext') -> List[Diagnostic]:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AnalysisContext:
|
|
40
|
+
"""Shared context for all rules during analysis."""
|
|
41
|
+
def __init__(self, source_lines: List[str], filepath: str):
|
|
42
|
+
self.source_lines = source_lines
|
|
43
|
+
self.filepath = filepath
|
|
44
|
+
self.tainted_vars: Dict[str, str] = {} # var_name -> taint_reason
|
|
45
|
+
self.fitted_before_split: Set[str] = set()
|
|
46
|
+
self.has_train_test_split = False
|
|
47
|
+
self.split_line: Optional[int] = None
|
|
48
|
+
self.in_loop = False
|
|
49
|
+
self.loop_var: Optional[str] = None
|
|
50
|
+
self.assignments: Dict[str, ast.AST] = {} # var_name -> assigned node
|
|
51
|
+
self.constants: Dict[str, any] = {} # var_name -> literal constant value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ═══════════════════════════════════════════════════════════════
|
|
55
|
+
# TEMPORAL LEAK RULES (T001–T015)
|
|
56
|
+
# ═══════════════════════════════════════════════════════════════
|
|
57
|
+
|
|
58
|
+
class T001_ShiftNegative(Rule):
|
|
59
|
+
code = "T001"
|
|
60
|
+
name = "future_shift"
|
|
61
|
+
severity = "error"
|
|
62
|
+
category = "TemporalLeak"
|
|
63
|
+
|
|
64
|
+
def check_node(self, node, ctx):
|
|
65
|
+
if not isinstance(node, ast.Call):
|
|
66
|
+
return []
|
|
67
|
+
# Standard: obj.shift(...)
|
|
68
|
+
is_shift = isinstance(node.func, ast.Attribute) and node.func.attr == 'shift'
|
|
69
|
+
# R2-9 fix: getattr(obj, 'shift')(...)
|
|
70
|
+
is_getattr_shift = (isinstance(node.func, ast.Call) and
|
|
71
|
+
isinstance(node.func.func, ast.Name) and
|
|
72
|
+
node.func.func.id == 'getattr' and
|
|
73
|
+
len(node.func.args) >= 2 and
|
|
74
|
+
isinstance(node.func.args[1], ast.Constant) and
|
|
75
|
+
node.func.args[1].value == 'shift')
|
|
76
|
+
if not (is_shift or is_getattr_shift):
|
|
77
|
+
return []
|
|
78
|
+
results = []
|
|
79
|
+
for arg in node.args:
|
|
80
|
+
val = self._get_negative_value(arg, ctx)
|
|
81
|
+
if val is not None and val > 0:
|
|
82
|
+
results.append(Diagnostic(
|
|
83
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
84
|
+
f".shift(-{val}) accesses data {val} rows INTO THE FUTURE",
|
|
85
|
+
f"Use .shift({val}) to access past data instead"))
|
|
86
|
+
# Taint the assignment target
|
|
87
|
+
self._taint_parent(node, ctx, f"shift(-{val}) future data")
|
|
88
|
+
for kw in node.keywords:
|
|
89
|
+
if kw.arg == 'periods':
|
|
90
|
+
val = self._get_negative_value(kw.value, ctx)
|
|
91
|
+
if val is not None and val > 0:
|
|
92
|
+
results.append(Diagnostic(
|
|
93
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
94
|
+
f".shift(periods=-{val}) accesses data {val} rows INTO THE FUTURE",
|
|
95
|
+
f"Use .shift(periods={val}) to access past data instead"))
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
def _get_negative_value(self, arg, ctx=None):
|
|
99
|
+
if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
|
|
100
|
+
if isinstance(arg.operand, ast.Constant) and isinstance(arg.operand.value, (int, float)):
|
|
101
|
+
return abs(arg.operand.value)
|
|
102
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
|
|
103
|
+
return abs(arg.value)
|
|
104
|
+
if isinstance(arg, ast.Name) and ctx is not None and hasattr(ctx, 'constants'):
|
|
105
|
+
val = ctx.constants.get(arg.id)
|
|
106
|
+
if val is not None and isinstance(val, (int, float)) and val < 0:
|
|
107
|
+
return abs(val)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _taint_parent(self, node, ctx, reason):
|
|
111
|
+
pass # Flow engine handles this
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class T002_TrainTestSplit(Rule):
|
|
115
|
+
code = "T002"
|
|
116
|
+
name = "random_split_timeseries"
|
|
117
|
+
severity = "error"
|
|
118
|
+
category = "TemporalLeak"
|
|
119
|
+
|
|
120
|
+
def check_node(self, node, ctx):
|
|
121
|
+
if not isinstance(node, ast.Call):
|
|
122
|
+
return []
|
|
123
|
+
name = self._get_call_name(node)
|
|
124
|
+
if name == 'train_test_split':
|
|
125
|
+
ctx.has_train_test_split = True
|
|
126
|
+
ctx.split_line = node.lineno
|
|
127
|
+
return [Diagnostic(
|
|
128
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
129
|
+
"train_test_split() shuffles time-series data, leaking future into training",
|
|
130
|
+
"Use a temporal split: train = data[:split_idx], test = data[split_idx:]")]
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
def _get_call_name(self, node):
|
|
134
|
+
if isinstance(node.func, ast.Name):
|
|
135
|
+
return node.func.id
|
|
136
|
+
if isinstance(node.func, ast.Attribute):
|
|
137
|
+
return node.func.attr
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class T003_RollingBeforeSplit(Rule):
|
|
142
|
+
code = "T003"
|
|
143
|
+
name = "rolling_before_split"
|
|
144
|
+
severity = "warning"
|
|
145
|
+
category = "TemporalLeak"
|
|
146
|
+
|
|
147
|
+
def check_node(self, node, ctx):
|
|
148
|
+
if not isinstance(node, ast.Call):
|
|
149
|
+
return []
|
|
150
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'rolling':
|
|
151
|
+
return [Diagnostic(
|
|
152
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
153
|
+
".rolling() computed on full dataset may include future data",
|
|
154
|
+
"Compute rolling statistics AFTER splitting into train/test sets")]
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class T004_ExpandingBeforeSplit(Rule):
|
|
159
|
+
code = "T004"
|
|
160
|
+
name = "expanding_before_split"
|
|
161
|
+
severity = "warning"
|
|
162
|
+
category = "TemporalLeak"
|
|
163
|
+
|
|
164
|
+
def check_node(self, node, ctx):
|
|
165
|
+
if not isinstance(node, ast.Call):
|
|
166
|
+
return []
|
|
167
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'expanding':
|
|
168
|
+
return [Diagnostic(
|
|
169
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
170
|
+
".expanding() computed on full dataset may include future data",
|
|
171
|
+
"Compute expanding statistics AFTER splitting into train/test sets")]
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class T005_PctChangeNegative(Rule):
|
|
176
|
+
code = "T005"
|
|
177
|
+
name = "future_pct_change"
|
|
178
|
+
severity = "error"
|
|
179
|
+
category = "TemporalLeak"
|
|
180
|
+
|
|
181
|
+
def check_node(self, node, ctx):
|
|
182
|
+
if not isinstance(node, ast.Call):
|
|
183
|
+
return []
|
|
184
|
+
if not (isinstance(node.func, ast.Attribute) and node.func.attr == 'pct_change'):
|
|
185
|
+
return []
|
|
186
|
+
for arg in node.args:
|
|
187
|
+
if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
|
|
188
|
+
if isinstance(arg.operand, ast.Constant):
|
|
189
|
+
return [Diagnostic(
|
|
190
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
191
|
+
f"pct_change with negative period accesses future data",
|
|
192
|
+
"Use positive periods to look backwards in time")]
|
|
193
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
|
|
194
|
+
return [Diagnostic(
|
|
195
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
196
|
+
f"pct_change({int(arg.value)}) accesses future data",
|
|
197
|
+
"Use positive periods to look backwards in time")]
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class T006_FutureIndexInLoop(Rule):
|
|
202
|
+
code = "T006"
|
|
203
|
+
name = "future_index_loop"
|
|
204
|
+
severity = "error"
|
|
205
|
+
category = "TemporalLeak"
|
|
206
|
+
|
|
207
|
+
def check_node(self, node, ctx):
|
|
208
|
+
if not isinstance(node, ast.Subscript):
|
|
209
|
+
return []
|
|
210
|
+
# Look for .iloc[i + 1] or [i + 1] patterns inside loops
|
|
211
|
+
if not ctx.in_loop or ctx.loop_var is None:
|
|
212
|
+
return []
|
|
213
|
+
sl = node.slice
|
|
214
|
+
if isinstance(sl, ast.BinOp) and isinstance(sl.op, ast.Add):
|
|
215
|
+
if self._uses_var(sl.left, ctx.loop_var) and isinstance(sl.right, ast.Constant):
|
|
216
|
+
if isinstance(sl.right.value, int) and sl.right.value > 0:
|
|
217
|
+
return [Diagnostic(
|
|
218
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
219
|
+
f"Indexing [{ctx.loop_var} + {sl.right.value}] inside loop accesses future data",
|
|
220
|
+
f"Only use [{ctx.loop_var}] or [{ctx.loop_var} - n] to access current/past data")]
|
|
221
|
+
if self._uses_var(sl.right, ctx.loop_var) and isinstance(sl.left, ast.Constant):
|
|
222
|
+
if isinstance(sl.left.value, int) and sl.left.value > 0:
|
|
223
|
+
return [Diagnostic(
|
|
224
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
225
|
+
f"Indexing [{sl.left.value} + {ctx.loop_var}] inside loop may access future data",
|
|
226
|
+
f"Verify this does not access future indices")]
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
def _uses_var(self, node, var_name):
|
|
230
|
+
if isinstance(node, ast.Name) and node.id == var_name:
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class T007_BackfillLeak(Rule):
|
|
236
|
+
code = "T007"
|
|
237
|
+
name = "backfill_leak"
|
|
238
|
+
severity = "error"
|
|
239
|
+
category = "TemporalLeak"
|
|
240
|
+
|
|
241
|
+
def check_node(self, node, ctx):
|
|
242
|
+
if not isinstance(node, ast.Call):
|
|
243
|
+
return []
|
|
244
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('fillna', 'bfill', 'backfill'):
|
|
245
|
+
if node.func.attr in ('bfill', 'backfill'):
|
|
246
|
+
return [Diagnostic(
|
|
247
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
248
|
+
f".{node.func.attr}() fills missing values with FUTURE data",
|
|
249
|
+
"Use .ffill() (forward fill) or .fillna(0) instead")]
|
|
250
|
+
# Check fillna(method='bfill') or fillna(method='backfill')
|
|
251
|
+
for kw in node.keywords:
|
|
252
|
+
if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
|
|
253
|
+
if kw.value.value in ('bfill', 'backfill'):
|
|
254
|
+
return [Diagnostic(
|
|
255
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
256
|
+
f".fillna(method='{kw.value.value}') fills missing values with FUTURE data",
|
|
257
|
+
"Use .fillna(method='ffill') or .fillna(0) instead")]
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class T008_NonCausalInterpolate(Rule):
|
|
262
|
+
code = "T008"
|
|
263
|
+
name = "noncausal_interpolate"
|
|
264
|
+
severity = "warning"
|
|
265
|
+
category = "TemporalLeak"
|
|
266
|
+
|
|
267
|
+
def check_node(self, node, ctx):
|
|
268
|
+
if not isinstance(node, ast.Call):
|
|
269
|
+
return []
|
|
270
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'interpolate':
|
|
271
|
+
safe_methods = {'pad', 'ffill', 'linear', 'nearest', 'zero', 'slinear'}
|
|
272
|
+
for kw in node.keywords:
|
|
273
|
+
if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
|
|
274
|
+
if kw.value.value in ('cubic', 'spline', 'polynomial', 'quadratic'):
|
|
275
|
+
return [Diagnostic(
|
|
276
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
277
|
+
f".interpolate(method='{kw.value.value}') uses future data points for interpolation",
|
|
278
|
+
"Use .interpolate(method='pad') or method='linear' with limit_direction='forward'")]
|
|
279
|
+
if kw.value.value in safe_methods:
|
|
280
|
+
return [] # Explicitly safe — no diagnostic
|
|
281
|
+
# No method keyword specified — default is 'linear' which can be non-causal
|
|
282
|
+
return [Diagnostic(
|
|
283
|
+
node.lineno, node.col_offset, "info", self.code,
|
|
284
|
+
".interpolate() may use future data depending on method",
|
|
285
|
+
"Ensure method is causal (e.g., method='pad' or method='ffill')")]
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class T009_CenteredRolling(Rule):
|
|
290
|
+
code = "T009"
|
|
291
|
+
name = "centered_rolling"
|
|
292
|
+
severity = "error"
|
|
293
|
+
category = "TemporalLeak"
|
|
294
|
+
|
|
295
|
+
def check_node(self, node, ctx):
|
|
296
|
+
if not isinstance(node, ast.Call):
|
|
297
|
+
return []
|
|
298
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'rolling':
|
|
299
|
+
for kw in node.keywords:
|
|
300
|
+
if kw.arg == 'center' and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
301
|
+
return [Diagnostic(
|
|
302
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
303
|
+
".rolling(center=True) uses future data on both sides of the window",
|
|
304
|
+
"Use .rolling(center=False) for causal (backward-looking) windows")]
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class T010_FitBeforeSplit(Rule):
|
|
309
|
+
code = "T010"
|
|
310
|
+
name = "fit_before_split"
|
|
311
|
+
severity = "error"
|
|
312
|
+
category = "TemporalLeak"
|
|
313
|
+
|
|
314
|
+
def _is_train_data(self, node):
|
|
315
|
+
"""Recursively check if an AST node represents training data."""
|
|
316
|
+
train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train')
|
|
317
|
+
if isinstance(node, ast.Name):
|
|
318
|
+
return any(m in node.id.lower() for m in train_markers)
|
|
319
|
+
if isinstance(node, ast.Subscript):
|
|
320
|
+
return self._is_train_data(node.value)
|
|
321
|
+
if isinstance(node, ast.Attribute):
|
|
322
|
+
return self._is_train_data(node.value)
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
def check_node(self, node, ctx):
|
|
326
|
+
if not isinstance(node, ast.Call):
|
|
327
|
+
return []
|
|
328
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('fit', 'fit_transform'):
|
|
329
|
+
# Only flag on objects that look like ML preprocessors/models
|
|
330
|
+
# Skip: fig.fit(), layout.fit(), etc.
|
|
331
|
+
obj_name = self._get_obj_name(node.func.value)
|
|
332
|
+
safe_objects = {'fig', 'figure', 'ax', 'axes', 'plt', 'layout',
|
|
333
|
+
'canvas', 'widget', 'app', 'db', 'session', 'socket',
|
|
334
|
+
'parser', 'formatter', 'logger', 'handler'}
|
|
335
|
+
if obj_name and obj_name.lower() in safe_objects:
|
|
336
|
+
return []
|
|
337
|
+
|
|
338
|
+
# Suppress if all positional arguments are clearly train-only variables
|
|
339
|
+
# (e.g. X_train, y_train, train_df[features], X_tr)
|
|
340
|
+
if node.args and all(self._is_train_data(a) for a in node.args):
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
if node.func.attr == 'fit':
|
|
344
|
+
return [Diagnostic(
|
|
345
|
+
node.lineno, node.col_offset, "warning", self.code,
|
|
346
|
+
".fit() may be called on the full dataset including test data",
|
|
347
|
+
"Call .fit() only on training data, use .transform() on test data")]
|
|
348
|
+
else:
|
|
349
|
+
return [Diagnostic(
|
|
350
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
351
|
+
".fit_transform() on full dataset leaks test statistics into training",
|
|
352
|
+
"Use .fit(X_train).transform(X_train), then .transform(X_test) separately")]
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
def _get_obj_name(self, node):
|
|
356
|
+
if isinstance(node, ast.Name):
|
|
357
|
+
return node.id
|
|
358
|
+
if isinstance(node, ast.Attribute):
|
|
359
|
+
return node.attr
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class T011_ScalerBeforeSplit(Rule):
|
|
364
|
+
code = "T011"
|
|
365
|
+
name = "scaler_before_split"
|
|
366
|
+
severity = "error"
|
|
367
|
+
category = "TemporalLeak"
|
|
368
|
+
|
|
369
|
+
def check_node(self, node, ctx):
|
|
370
|
+
if not isinstance(node, ast.Call):
|
|
371
|
+
return []
|
|
372
|
+
scaler_names = {'StandardScaler', 'MinMaxScaler', 'RobustScaler',
|
|
373
|
+
'MaxAbsScaler', 'Normalizer', 'QuantileTransformer',
|
|
374
|
+
'PowerTransformer'}
|
|
375
|
+
if isinstance(node.func, ast.Name) and node.func.id in scaler_names:
|
|
376
|
+
return [Diagnostic(
|
|
377
|
+
node.lineno, node.col_offset, "info", self.code,
|
|
378
|
+
f"{node.func.id}() — ensure this is fit ONLY on training data",
|
|
379
|
+
"Fit the scaler on X_train, then transform both X_train and X_test")]
|
|
380
|
+
return []
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class T012_KFoldTimeSeries(Rule):
|
|
384
|
+
code = "T012"
|
|
385
|
+
name = "kfold_timeseries"
|
|
386
|
+
severity = "error"
|
|
387
|
+
category = "TemporalLeak"
|
|
388
|
+
|
|
389
|
+
def check_node(self, node, ctx):
|
|
390
|
+
if not isinstance(node, ast.Call):
|
|
391
|
+
return []
|
|
392
|
+
kfold_names = {'KFold', 'StratifiedKFold', 'RepeatedKFold', 'ShuffleSplit'}
|
|
393
|
+
name = ""
|
|
394
|
+
if isinstance(node.func, ast.Name):
|
|
395
|
+
name = node.func.id
|
|
396
|
+
elif isinstance(node.func, ast.Attribute):
|
|
397
|
+
name = node.func.attr
|
|
398
|
+
if name in kfold_names:
|
|
399
|
+
return [Diagnostic(
|
|
400
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
401
|
+
f"{name}() shuffles time-series data across folds, leaking future into training",
|
|
402
|
+
"Use TimeSeriesSplit() for temporal cross-validation")]
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class T013_ResampleBeforeSplit(Rule):
|
|
407
|
+
code = "T013"
|
|
408
|
+
name = "resample_before_split"
|
|
409
|
+
severity = "warning"
|
|
410
|
+
category = "TemporalLeak"
|
|
411
|
+
|
|
412
|
+
def check_node(self, node, ctx):
|
|
413
|
+
if not isinstance(node, ast.Call):
|
|
414
|
+
return []
|
|
415
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'resample':
|
|
416
|
+
return [Diagnostic(
|
|
417
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
418
|
+
".resample() on full dataset may leak future aggregate statistics",
|
|
419
|
+
"Resample AFTER splitting into train/test sets")]
|
|
420
|
+
return []
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class T014_DiffNegative(Rule):
|
|
424
|
+
code = "T014"
|
|
425
|
+
name = "future_diff"
|
|
426
|
+
severity = "error"
|
|
427
|
+
category = "TemporalLeak"
|
|
428
|
+
|
|
429
|
+
def check_node(self, node, ctx):
|
|
430
|
+
if not isinstance(node, ast.Call):
|
|
431
|
+
return []
|
|
432
|
+
if not (isinstance(node.func, ast.Attribute) and node.func.attr == 'diff'):
|
|
433
|
+
return []
|
|
434
|
+
results = []
|
|
435
|
+
for arg in node.args:
|
|
436
|
+
val = self._get_negative_value(arg, ctx)
|
|
437
|
+
if val is not None and val > 0:
|
|
438
|
+
results.append(Diagnostic(
|
|
439
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
440
|
+
f".diff(-{val}) computes difference using future data",
|
|
441
|
+
f"Use .diff({val}) to compute backward differences"))
|
|
442
|
+
for kw in node.keywords:
|
|
443
|
+
if kw.arg == 'periods':
|
|
444
|
+
val = self._get_negative_value(kw.value, ctx)
|
|
445
|
+
if val is not None and val > 0:
|
|
446
|
+
results.append(Diagnostic(
|
|
447
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
448
|
+
f".diff(periods=-{val}) computes difference using future data",
|
|
449
|
+
f"Use .diff(periods={val}) to compute backward differences"))
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
def _get_negative_value(self, arg, ctx=None):
|
|
453
|
+
if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
|
|
454
|
+
if isinstance(arg.operand, ast.Constant) and isinstance(arg.operand.value, (int, float)):
|
|
455
|
+
return abs(arg.operand.value)
|
|
456
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)) and arg.value < 0:
|
|
457
|
+
return abs(arg.value)
|
|
458
|
+
if isinstance(arg, ast.Name) and ctx is not None and hasattr(ctx, 'constants'):
|
|
459
|
+
val = ctx.constants.get(arg.id)
|
|
460
|
+
if val is not None and isinstance(val, (int, float)) and val < 0:
|
|
461
|
+
return abs(val)
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class T015_GroupbyShiftLeak(Rule):
|
|
466
|
+
code = "T015"
|
|
467
|
+
name = "groupby_shift_leak"
|
|
468
|
+
severity = "error"
|
|
469
|
+
category = "TemporalLeak"
|
|
470
|
+
|
|
471
|
+
def check_node(self, node, ctx):
|
|
472
|
+
if not isinstance(node, ast.Call):
|
|
473
|
+
return []
|
|
474
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('shift', 'diff', 'pct_change'):
|
|
475
|
+
# Check if this is chained after .transform() or .apply()
|
|
476
|
+
obj = node.func.value
|
|
477
|
+
if isinstance(obj, ast.Call) and isinstance(obj.func, ast.Attribute):
|
|
478
|
+
if obj.func.attr in ('transform', 'apply'):
|
|
479
|
+
for arg in node.args:
|
|
480
|
+
if isinstance(arg, ast.UnaryOp) and isinstance(arg.op, ast.USub):
|
|
481
|
+
return [Diagnostic(
|
|
482
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
483
|
+
f"Negative {node.func.attr}() inside groupby().transform() leaks future within groups",
|
|
484
|
+
"Use positive periods for backward-looking operations within groups")]
|
|
485
|
+
return []
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class T016_MergeAsofDirection(Rule):
|
|
489
|
+
code = "T016"
|
|
490
|
+
name = "merge_asof_future_direction"
|
|
491
|
+
severity = "error"
|
|
492
|
+
category = "TemporalLeak"
|
|
493
|
+
|
|
494
|
+
def check_node(self, node, ctx):
|
|
495
|
+
if not isinstance(node, ast.Call):
|
|
496
|
+
return []
|
|
497
|
+
is_merge_asof = False
|
|
498
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'merge_asof':
|
|
499
|
+
is_merge_asof = True
|
|
500
|
+
elif isinstance(node.func, ast.Name) and node.func.id == 'merge_asof':
|
|
501
|
+
is_merge_asof = True
|
|
502
|
+
|
|
503
|
+
if not is_merge_asof:
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
for kw in node.keywords:
|
|
507
|
+
if kw.arg == 'direction':
|
|
508
|
+
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
509
|
+
val = kw.value.value.lower()
|
|
510
|
+
if val in ('nearest', 'forward'):
|
|
511
|
+
return [Diagnostic(
|
|
512
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
513
|
+
f"pd.merge_asof() with direction='{kw.value.value}' matches future timestamps (lookahead bias)",
|
|
514
|
+
"Use direction='backward' for point-in-time causal event joins")]
|
|
515
|
+
return []
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class T017_TargetLaundering(Rule):
|
|
519
|
+
code = "T017"
|
|
520
|
+
name = "target_laundering_leak"
|
|
521
|
+
severity = "error"
|
|
522
|
+
category = "TemporalLeak"
|
|
523
|
+
|
|
524
|
+
def check_node(self, node, ctx):
|
|
525
|
+
target_col_names = {'target', 'success', 'outcome', 'label', 'labels', 'y',
|
|
526
|
+
'profit', 'pnl', 'win', 'return', 'returns', 'future_price'}
|
|
527
|
+
|
|
528
|
+
# 1. Detect target encoding / aggregation: df.groupby(...)['target'].transform('mean')
|
|
529
|
+
if isinstance(node, ast.Call):
|
|
530
|
+
if isinstance(node.func, ast.Name) and node.func.id in ('TargetEncoder', 'CatBoostEncoder'):
|
|
531
|
+
return [Diagnostic(
|
|
532
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
533
|
+
f"{node.func.id}() calculates target statistics across groups; ensure it is fit ONLY on training data",
|
|
534
|
+
"Fit target encoders strictly on X_train, y_train after temporal splitting")]
|
|
535
|
+
|
|
536
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('transform', 'mean', 'median', 'std', 'sum', 'agg', 'apply'):
|
|
537
|
+
val = node.func.value
|
|
538
|
+
col_name = None
|
|
539
|
+
groupby_call = None
|
|
540
|
+
|
|
541
|
+
if isinstance(val, ast.Subscript):
|
|
542
|
+
if isinstance(val.slice, ast.Constant) and isinstance(val.slice.value, str):
|
|
543
|
+
col_name = val.slice.value.lower()
|
|
544
|
+
groupby_call = val.value
|
|
545
|
+
elif isinstance(val, ast.Call):
|
|
546
|
+
groupby_call = val
|
|
547
|
+
|
|
548
|
+
if col_name and any(t in col_name for t in target_col_names):
|
|
549
|
+
if isinstance(groupby_call, ast.Call) and isinstance(groupby_call.func, ast.Attribute) and groupby_call.func.attr == 'groupby':
|
|
550
|
+
return [Diagnostic(
|
|
551
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
552
|
+
f"Target laundering: grouping by target/outcome column '{col_name}' leaks future labels into features",
|
|
553
|
+
"Compute regime/group statistics strictly on training data after splitting")]
|
|
554
|
+
|
|
555
|
+
# 2. Detect assignment creating laundered columns: df['regime_success_rate'] = ...
|
|
556
|
+
if isinstance(node, ast.Assign):
|
|
557
|
+
for target in node.targets:
|
|
558
|
+
if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, str):
|
|
559
|
+
col = target.slice.value.lower()
|
|
560
|
+
if any(marker in col for marker in ('success_rate', 'target_enc', 'target_mean', 'regime_success')):
|
|
561
|
+
return [Diagnostic(
|
|
562
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
563
|
+
f"Target laundering: column '{target.slice.value}' encodes target outcomes across full dataset",
|
|
564
|
+
"Calculate success rates and target statistics only on the training set")]
|
|
565
|
+
return []
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ═══════════════════════════════════════════════════════════════
|
|
569
|
+
# CAUSAL CONFUSION RULES (C001–C010)
|
|
570
|
+
# ═══════════════════════════════════════════════════════════════
|
|
571
|
+
|
|
572
|
+
class C001_CorrelationCausation(Rule):
|
|
573
|
+
code = "C001"
|
|
574
|
+
name = "correlation_causation"
|
|
575
|
+
severity = "warning"
|
|
576
|
+
category = "CausalConfusion"
|
|
577
|
+
|
|
578
|
+
def check_node(self, node, ctx):
|
|
579
|
+
if not isinstance(node, ast.Call):
|
|
580
|
+
return []
|
|
581
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'corr':
|
|
582
|
+
return [Diagnostic(
|
|
583
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
584
|
+
".corr() measures correlation, not causation — do not use for treatment/trading decisions",
|
|
585
|
+
"Use causal inference methods (DoWhy, EconML) or randomized experiments")]
|
|
586
|
+
return []
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class C002_TargetInFeatures(Rule):
|
|
590
|
+
code = "C002"
|
|
591
|
+
name = "target_in_features"
|
|
592
|
+
severity = "error"
|
|
593
|
+
category = "CausalConfusion"
|
|
594
|
+
|
|
595
|
+
def check_node(self, node, ctx):
|
|
596
|
+
if not isinstance(node, ast.Subscript):
|
|
597
|
+
return []
|
|
598
|
+
# Detect df[['feature1', 'target']] where target-like names are used as features
|
|
599
|
+
if isinstance(node.slice, ast.List):
|
|
600
|
+
target_names = {'target', 'y', 'label', 'labels', 'outcome', 'y_true',
|
|
601
|
+
'future_price', 'future_return', 'next_close', 'next_price',
|
|
602
|
+
'regime_success_rate', 'success_rate'}
|
|
603
|
+
for elt in node.slice.elts:
|
|
604
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
605
|
+
val = elt.value.lower()
|
|
606
|
+
if val in target_names or val.endswith('_success_rate') or val.endswith('_target_enc'):
|
|
607
|
+
return [Diagnostic(
|
|
608
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
609
|
+
f"Column '{elt.value}' appears to be a target or target-laundered variable used as a feature",
|
|
610
|
+
"Remove target/outcome and target-laundered columns from feature inputs to avoid data leakage")]
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class C003_PostTreatmentVariable(Rule):
|
|
615
|
+
code = "C003"
|
|
616
|
+
name = "post_treatment_variable"
|
|
617
|
+
severity = "warning"
|
|
618
|
+
category = "CausalConfusion"
|
|
619
|
+
|
|
620
|
+
def check_node(self, node, ctx):
|
|
621
|
+
# Detect variable names that suggest post-treatment data
|
|
622
|
+
if not isinstance(node, ast.Assign):
|
|
623
|
+
return []
|
|
624
|
+
for target in node.targets:
|
|
625
|
+
if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant):
|
|
626
|
+
col_name = str(target.slice.value).lower()
|
|
627
|
+
post_indicators = ('after_', 'post_', 'result_', 'outcome_', 'response_')
|
|
628
|
+
if any(col_name.startswith(p) for p in post_indicators):
|
|
629
|
+
return [Diagnostic(
|
|
630
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
631
|
+
f"Column '{target.slice.value}' may be a post-treatment variable",
|
|
632
|
+
"Post-treatment variables should not be used as features in causal models")]
|
|
633
|
+
return []
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class C004_SelectionOnOutcome(Rule):
|
|
637
|
+
code = "C004"
|
|
638
|
+
name = "selection_on_outcome"
|
|
639
|
+
severity = "error"
|
|
640
|
+
category = "CausalConfusion"
|
|
641
|
+
|
|
642
|
+
def check_node(self, node, ctx):
|
|
643
|
+
if not isinstance(node, ast.Assign):
|
|
644
|
+
return []
|
|
645
|
+
val = node.value
|
|
646
|
+
# Detect df = df[df['profit'] > 0] patterns (survivorship bias)
|
|
647
|
+
if isinstance(val, ast.Subscript) and isinstance(val.slice, ast.Compare):
|
|
648
|
+
comp = val.slice
|
|
649
|
+
if isinstance(comp.left, ast.Subscript) and isinstance(comp.left.slice, ast.Constant):
|
|
650
|
+
col = str(comp.left.slice.value).lower()
|
|
651
|
+
outcome_cols = {'profit', 'return', 'returns', 'pnl', 'outcome',
|
|
652
|
+
'survived', 'alive', 'active', 'success'}
|
|
653
|
+
if col in outcome_cols:
|
|
654
|
+
return [Diagnostic(
|
|
655
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
656
|
+
f"Filtering by outcome column '{comp.left.slice.value}' before analysis introduces survivorship bias",
|
|
657
|
+
"Include all data points, including failures, in your analysis")]
|
|
658
|
+
return []
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class C005_SurvivorshipBias(Rule):
|
|
662
|
+
code = "C005"
|
|
663
|
+
name = "survivorship_bias"
|
|
664
|
+
severity = "warning"
|
|
665
|
+
category = "CausalConfusion"
|
|
666
|
+
|
|
667
|
+
def check_node(self, node, ctx):
|
|
668
|
+
if not isinstance(node, ast.Call):
|
|
669
|
+
return []
|
|
670
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'dropna':
|
|
671
|
+
return [Diagnostic(
|
|
672
|
+
node.lineno, node.col_offset, "info", self.code,
|
|
673
|
+
".dropna() removes rows non-randomly — may introduce survivorship bias",
|
|
674
|
+
"Consider imputation (.fillna()) or analyze the pattern of missing data first")]
|
|
675
|
+
return []
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class C006_PHacking(Rule):
|
|
679
|
+
code = "C006"
|
|
680
|
+
name = "p_hacking"
|
|
681
|
+
severity = "warning"
|
|
682
|
+
category = "CausalConfusion"
|
|
683
|
+
|
|
684
|
+
def check_node(self, node, ctx):
|
|
685
|
+
if not isinstance(node, ast.Compare):
|
|
686
|
+
return []
|
|
687
|
+
# Detect patterns like p_value < 0.05
|
|
688
|
+
if isinstance(node.left, ast.Name):
|
|
689
|
+
if 'p_val' in node.left.id.lower() or 'pvalue' in node.left.id.lower():
|
|
690
|
+
return [Diagnostic(
|
|
691
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
692
|
+
"Comparing p-values to thresholds — risk of p-hacking with multiple comparisons",
|
|
693
|
+
"Apply Bonferroni correction or use FDR control for multiple hypothesis testing")]
|
|
694
|
+
return []
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class C007_CorrWithoutForDecisions(Rule):
|
|
698
|
+
code = "C007"
|
|
699
|
+
name = "corrwith_decisions"
|
|
700
|
+
severity = "warning"
|
|
701
|
+
category = "CausalConfusion"
|
|
702
|
+
|
|
703
|
+
def check_node(self, node, ctx):
|
|
704
|
+
if not isinstance(node, ast.Call):
|
|
705
|
+
return []
|
|
706
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'corrwith':
|
|
707
|
+
return [Diagnostic(
|
|
708
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
709
|
+
".corrwith() computes correlation — do not use for causal feature selection",
|
|
710
|
+
"Use mutual information, SHAP values, or causal feature selection instead")]
|
|
711
|
+
return []
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ═══════════════════════════════════════════════════════════════
|
|
715
|
+
# UNCERTAINTY RULES (U001–U008)
|
|
716
|
+
# ═══════════════════════════════════════════════════════════════
|
|
717
|
+
|
|
718
|
+
class U001_PredictNoUncertainty(Rule):
|
|
719
|
+
code = "U001"
|
|
720
|
+
name = "predict_no_uncertainty"
|
|
721
|
+
severity = "warning"
|
|
722
|
+
category = "UncertaintyIgnored"
|
|
723
|
+
|
|
724
|
+
def check_node(self, node, ctx):
|
|
725
|
+
if not isinstance(node, ast.Call):
|
|
726
|
+
return []
|
|
727
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict':
|
|
728
|
+
return [Diagnostic(
|
|
729
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
730
|
+
"model.predict() returns point estimates without confidence scores",
|
|
731
|
+
"Use model.predict_proba() or compute prediction intervals to quantify uncertainty")]
|
|
732
|
+
return []
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class U002_HardcodedThreshold(Rule):
|
|
736
|
+
code = "U002"
|
|
737
|
+
name = "hardcoded_threshold"
|
|
738
|
+
severity = "warning"
|
|
739
|
+
category = "UncertaintyIgnored"
|
|
740
|
+
|
|
741
|
+
def check_node(self, node, ctx):
|
|
742
|
+
if not isinstance(node, ast.Compare):
|
|
743
|
+
return []
|
|
744
|
+
# Detect: prediction > 0.5, signal > 0.7, etc.
|
|
745
|
+
if isinstance(node.left, ast.Name):
|
|
746
|
+
pred_names = {'prediction', 'pred', 'signal', 'score', 'probability', 'prob', 'confidence'}
|
|
747
|
+
if node.left.id.lower() in pred_names:
|
|
748
|
+
for comp_val in node.comparators:
|
|
749
|
+
if isinstance(comp_val, ast.Constant) and isinstance(comp_val.value, (int, float)):
|
|
750
|
+
return [Diagnostic(
|
|
751
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
752
|
+
f"Hardcoded threshold ({comp_val.value}) on '{node.left.id}' ignores prediction uncertainty",
|
|
753
|
+
"Use calibrated probability thresholds or dynamic thresholds based on confidence intervals")]
|
|
754
|
+
return []
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
class U003_NoCalibration(Rule):
|
|
758
|
+
code = "U003"
|
|
759
|
+
name = "no_calibration"
|
|
760
|
+
severity = "warning"
|
|
761
|
+
category = "UncertaintyIgnored"
|
|
762
|
+
|
|
763
|
+
def check_node(self, node, ctx):
|
|
764
|
+
if not isinstance(node, ast.Call):
|
|
765
|
+
return []
|
|
766
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict_proba':
|
|
767
|
+
return [Diagnostic(
|
|
768
|
+
node.lineno, node.col_offset, "info", self.code,
|
|
769
|
+
".predict_proba() output may not be calibrated — probabilities may not reflect true likelihoods",
|
|
770
|
+
"Use CalibratedClassifierCV or check calibration with calibration_curve()")]
|
|
771
|
+
return []
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
class U004_NoEnsemble(Rule):
|
|
775
|
+
code = "U004"
|
|
776
|
+
name = "single_model"
|
|
777
|
+
severity = "info"
|
|
778
|
+
category = "UncertaintyIgnored"
|
|
779
|
+
|
|
780
|
+
def check_node(self, node, ctx):
|
|
781
|
+
if not isinstance(node, ast.Call):
|
|
782
|
+
return []
|
|
783
|
+
single_models = {'LinearRegression', 'LogisticRegression', 'DecisionTreeClassifier',
|
|
784
|
+
'DecisionTreeRegressor', 'SVR', 'SVC', 'KNeighborsClassifier',
|
|
785
|
+
'SGDClassifier', 'SGDRegressor', 'Perceptron'}
|
|
786
|
+
name = ""
|
|
787
|
+
if isinstance(node.func, ast.Name):
|
|
788
|
+
name = node.func.id
|
|
789
|
+
elif isinstance(node.func, ast.Attribute):
|
|
790
|
+
name = node.func.attr
|
|
791
|
+
if name in single_models:
|
|
792
|
+
return [Diagnostic(
|
|
793
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
794
|
+
f"{name}() — single model provides no uncertainty estimate",
|
|
795
|
+
"Consider ensemble methods (RandomForest, GradientBoosting) or Bayesian approaches for uncertainty")]
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
class U005_ScoreOnTrainOnly(Rule):
|
|
800
|
+
code = "U005"
|
|
801
|
+
name = "score_train_only"
|
|
802
|
+
severity = "error"
|
|
803
|
+
category = "UncertaintyIgnored"
|
|
804
|
+
|
|
805
|
+
def check_node(self, node, ctx):
|
|
806
|
+
if not isinstance(node, ast.Call):
|
|
807
|
+
return []
|
|
808
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'score':
|
|
809
|
+
# Check if the args look like training data
|
|
810
|
+
for arg in node.args:
|
|
811
|
+
if isinstance(arg, ast.Name):
|
|
812
|
+
name_lower = arg.id.lower()
|
|
813
|
+
if 'train' in name_lower and 'test' not in name_lower:
|
|
814
|
+
return [Diagnostic(
|
|
815
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
816
|
+
f".score({arg.id}) evaluates on training data — this is overfitting, not validation",
|
|
817
|
+
"Evaluate on held-out test data: model.score(X_test, y_test)")]
|
|
818
|
+
return []
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
class U006_PredictInLoop(Rule):
|
|
822
|
+
code = "U006"
|
|
823
|
+
name = "predict_production_loop"
|
|
824
|
+
severity = "error"
|
|
825
|
+
category = "UncertaintyIgnored"
|
|
826
|
+
|
|
827
|
+
def check_node(self, node, ctx):
|
|
828
|
+
if not isinstance(node, ast.Call):
|
|
829
|
+
return []
|
|
830
|
+
if not ctx.in_loop:
|
|
831
|
+
return []
|
|
832
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == 'predict':
|
|
833
|
+
return [Diagnostic(
|
|
834
|
+
node.lineno, node.col_offset, "warning", self.code,
|
|
835
|
+
".predict() inside a loop without uncertainty check — risky for live trading/production",
|
|
836
|
+
"Add confidence thresholds or uncertainty bounds before acting on predictions")]
|
|
837
|
+
return []
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ═══════════════════════════════════════════════════════════════
|
|
841
|
+
# DATA QUALITY RULES (D001–D007)
|
|
842
|
+
# ═══════════════════════════════════════════════════════════════
|
|
843
|
+
|
|
844
|
+
class D001_DropNaBeforeSplit(Rule):
|
|
845
|
+
code = "D001"
|
|
846
|
+
name = "dropna_before_split"
|
|
847
|
+
severity = "warning"
|
|
848
|
+
category = "DataQuality"
|
|
849
|
+
|
|
850
|
+
def check_node(self, node, ctx):
|
|
851
|
+
# Handled by C005 (survivorship), but with different framing
|
|
852
|
+
return []
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
class D002_SilentExceptionSwallow(Rule):
|
|
856
|
+
code = "D002"
|
|
857
|
+
name = "silent_exception"
|
|
858
|
+
severity = "warning"
|
|
859
|
+
category = "DataQuality"
|
|
860
|
+
|
|
861
|
+
def check_node(self, node, ctx):
|
|
862
|
+
if not isinstance(node, ast.ExceptHandler):
|
|
863
|
+
return []
|
|
864
|
+
# Check for bare `except: pass`
|
|
865
|
+
if node.type is None and len(node.body) == 1:
|
|
866
|
+
if isinstance(node.body[0], ast.Pass):
|
|
867
|
+
return [Diagnostic(
|
|
868
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
869
|
+
"Bare `except: pass` silently swallows errors in data pipeline",
|
|
870
|
+
"Catch specific exceptions and log or handle them properly")]
|
|
871
|
+
return []
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
class D003_MagicNumbers(Rule):
|
|
875
|
+
code = "D003"
|
|
876
|
+
name = "magic_numbers"
|
|
877
|
+
severity = "info"
|
|
878
|
+
category = "DataQuality"
|
|
879
|
+
|
|
880
|
+
def check_node(self, node, ctx):
|
|
881
|
+
# Detect hardcoded numeric constants in function calls
|
|
882
|
+
if not isinstance(node, ast.Call):
|
|
883
|
+
return []
|
|
884
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ('rolling', 'shift', 'head', 'tail', 'nlargest', 'nsmallest'):
|
|
885
|
+
for arg in node.args:
|
|
886
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, (int, float)):
|
|
887
|
+
if arg.value > 100 or (isinstance(arg.value, float) and arg.value != int(arg.value)):
|
|
888
|
+
return [Diagnostic(
|
|
889
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
890
|
+
f"Magic number {arg.value} in .{node.func.attr}() — consider using a named constant",
|
|
891
|
+
"Define as a variable: WINDOW_SIZE = {arg.value}")]
|
|
892
|
+
return []
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# ═══════════════════════════════════════════════════════════════
|
|
896
|
+
# RULE REGISTRY
|
|
897
|
+
# ═══════════════════════════════════════════════════════════════
|
|
898
|
+
|
|
899
|
+
ALL_RULES: List[Rule] = [
|
|
900
|
+
# Temporal Leak
|
|
901
|
+
T001_ShiftNegative(),
|
|
902
|
+
T002_TrainTestSplit(),
|
|
903
|
+
T003_RollingBeforeSplit(),
|
|
904
|
+
T004_ExpandingBeforeSplit(),
|
|
905
|
+
T005_PctChangeNegative(),
|
|
906
|
+
T006_FutureIndexInLoop(),
|
|
907
|
+
T007_BackfillLeak(),
|
|
908
|
+
T008_NonCausalInterpolate(),
|
|
909
|
+
T009_CenteredRolling(),
|
|
910
|
+
T010_FitBeforeSplit(),
|
|
911
|
+
T011_ScalerBeforeSplit(),
|
|
912
|
+
T012_KFoldTimeSeries(),
|
|
913
|
+
T013_ResampleBeforeSplit(),
|
|
914
|
+
T014_DiffNegative(),
|
|
915
|
+
T015_GroupbyShiftLeak(),
|
|
916
|
+
T016_MergeAsofDirection(),
|
|
917
|
+
T017_TargetLaundering(),
|
|
918
|
+
# Causal Confusion
|
|
919
|
+
C001_CorrelationCausation(),
|
|
920
|
+
C002_TargetInFeatures(),
|
|
921
|
+
C003_PostTreatmentVariable(),
|
|
922
|
+
C004_SelectionOnOutcome(),
|
|
923
|
+
C005_SurvivorshipBias(),
|
|
924
|
+
C006_PHacking(),
|
|
925
|
+
C007_CorrWithoutForDecisions(),
|
|
926
|
+
# Uncertainty
|
|
927
|
+
U001_PredictNoUncertainty(),
|
|
928
|
+
U002_HardcodedThreshold(),
|
|
929
|
+
U003_NoCalibration(),
|
|
930
|
+
U004_NoEnsemble(),
|
|
931
|
+
U005_ScoreOnTrainOnly(),
|
|
932
|
+
U006_PredictInLoop(),
|
|
933
|
+
# Data Quality
|
|
934
|
+
D002_SilentExceptionSwallow(),
|
|
935
|
+
D003_MagicNumbers(),
|
|
936
|
+
]
|
|
937
|
+
|
|
938
|
+
RULES_BY_CODE = {r.code: r for r in ALL_RULES}
|