learnstat 0.2.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.
- learnstat/__init__.py +80 -0
- learnstat/advisor.py +502 -0
- learnstat/cli.py +338 -0
- learnstat/commands.py +317 -0
- learnstat/config.py +223 -0
- learnstat/ipython_ext.py +104 -0
- learnstat/knowledge.py +233 -0
- learnstat/llm.py +280 -0
- learnstat/memory.py +193 -0
- learnstat/profile.py +344 -0
- learnstat/providers.py +78 -0
- learnstat/recovery.py +1021 -0
- learnstat/safety.py +100 -0
- learnstat/session.py +307 -0
- learnstat/setup.py +308 -0
- learnstat/suggestions.py +64 -0
- learnstat-0.2.0.dist-info/METADATA +199 -0
- learnstat-0.2.0.dist-info/RECORD +22 -0
- learnstat-0.2.0.dist-info/WHEEL +5 -0
- learnstat-0.2.0.dist-info/entry_points.txt +2 -0
- learnstat-0.2.0.dist-info/licenses/LICENSE +21 -0
- learnstat-0.2.0.dist-info/top_level.txt +1 -0
learnstat/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""learnstat - an AI-assisted learning companion for statistics in Python.
|
|
2
|
+
|
|
3
|
+
You write ordinary pandas / NumPy / SciPy / Matplotlib code; learnstat profiles
|
|
4
|
+
your data, suggests better approaches, explains its reasoning and helps you
|
|
5
|
+
recover from mistakes.
|
|
6
|
+
|
|
7
|
+
import learnstat as ls
|
|
8
|
+
df = ls.read_csv("survey.csv") # a normal pandas DataFrame, now profiled
|
|
9
|
+
ls.check("df['income'].mean()") # suggestions, without running anything
|
|
10
|
+
print(ls.command("/describe")) # why?
|
|
11
|
+
|
|
12
|
+
In Jupyter: ``%load_ext learnstat``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .advisor import CodeAdvisor
|
|
18
|
+
from .memory import MemoryStore
|
|
19
|
+
from .profile import DatasetProfile, profile_dataframe
|
|
20
|
+
from .recovery import Executor, RunResult
|
|
21
|
+
from .session import Session, get_session, set_session
|
|
22
|
+
from .suggestions import Suggestion
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_csv(path, name: str | None = None, **kwargs):
|
|
28
|
+
"""``pandas.read_csv`` plus dataset profiling and persistent memory."""
|
|
29
|
+
return get_session().read_csv(path, name=name, **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_excel(path, name: str | None = None, **kwargs):
|
|
33
|
+
return get_session().read_excel(path, name=name, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_parquet(path, name: str | None = None, **kwargs):
|
|
37
|
+
return get_session().read_parquet(path, name=name, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_json(path, name: str | None = None, **kwargs):
|
|
41
|
+
return get_session().read_json(path, name=name, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def track(df, name: str | None = None, **kwargs):
|
|
45
|
+
"""Profile a DataFrame you already have."""
|
|
46
|
+
return get_session().track(df, name=name, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check(code: str, show: bool = True):
|
|
50
|
+
"""Suggestions for ``code`` (it is not executed)."""
|
|
51
|
+
return get_session().check(code, show=show)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(code: str):
|
|
55
|
+
"""Run ``code`` in the default session with suggestions and guarded recovery."""
|
|
56
|
+
return get_session().run(code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def command(text: str) -> str:
|
|
60
|
+
"""Run a slash command such as ``/describe`` and return its text."""
|
|
61
|
+
return get_session().command(text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_ipython_extension(ip):
|
|
65
|
+
from .ipython_ext import load_ipython_extension as _load
|
|
66
|
+
|
|
67
|
+
_load(ip)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unload_ipython_extension(ip):
|
|
71
|
+
from .ipython_ext import unload_ipython_extension as _unload
|
|
72
|
+
|
|
73
|
+
_unload(ip)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"Session", "get_session", "set_session", "read_csv", "read_excel", "read_parquet", "read_json",
|
|
78
|
+
"track", "check", "run", "command", "profile_dataframe", "DatasetProfile", "MemoryStore",
|
|
79
|
+
"CodeAdvisor", "Executor", "RunResult", "Suggestion",
|
|
80
|
+
]
|
learnstat/advisor.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Context-aware code advice.
|
|
2
|
+
|
|
3
|
+
The advisor reads the user's code with ``ast`` (it never executes it), works
|
|
4
|
+
out which dataset columns each call touches, and checks the call against the
|
|
5
|
+
dataset profile. Every rule returns a :class:`Suggestion` that explains the
|
|
6
|
+
problem, the alternative, the reason and how to implement it. The user's code
|
|
7
|
+
is never modified.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import difflib
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Iterator
|
|
16
|
+
|
|
17
|
+
from .profile import ColumnProfile, DatasetProfile
|
|
18
|
+
from .suggestions import Suggestion
|
|
19
|
+
|
|
20
|
+
PIE_MAX_CATEGORIES = 6
|
|
21
|
+
SKEW_THRESHOLD = 1.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# AST helpers
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dotted_name(node: ast.AST) -> str | None:
|
|
29
|
+
"""``plt.pie`` -> 'plt.pie', ``stats.ttest_ind`` -> 'stats.ttest_ind'."""
|
|
30
|
+
parts = []
|
|
31
|
+
while isinstance(node, ast.Attribute):
|
|
32
|
+
parts.append(node.attr)
|
|
33
|
+
node = node.value
|
|
34
|
+
if isinstance(node, ast.Name):
|
|
35
|
+
parts.append(node.id)
|
|
36
|
+
return ".".join(reversed(parts))
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def call_name(call: ast.Call) -> str:
|
|
41
|
+
"""Last attribute of a call: ``df['a'].value_counts().plot.pie()`` -> 'pie'."""
|
|
42
|
+
f = call.func
|
|
43
|
+
if isinstance(f, ast.Attribute):
|
|
44
|
+
return f.attr
|
|
45
|
+
if isinstance(f, ast.Name):
|
|
46
|
+
return f.id
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def kwarg(call: ast.Call, name: str) -> ast.AST | None:
|
|
51
|
+
return next((k.value for k in call.keywords if k.arg == name), None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def const_value(node: ast.AST | None):
|
|
55
|
+
return node.value if isinstance(node, ast.Constant) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def frame_base(node: ast.AST) -> ast.Name | None:
|
|
59
|
+
"""The DataFrame variable behind ``df`` or a row filter like ``df[df['g'] == 'a']``."""
|
|
60
|
+
while isinstance(node, ast.Subscript) and not isinstance(const_value(node.slice), str):
|
|
61
|
+
node = node.value
|
|
62
|
+
return node if isinstance(node, ast.Name) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ColumnRef:
|
|
67
|
+
frame: str | None # variable name of the DataFrame, if known
|
|
68
|
+
column: str
|
|
69
|
+
profile: DatasetProfile
|
|
70
|
+
info: ColumnProfile
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Resolver:
|
|
74
|
+
"""Maps ``df['col']`` / ``df.col`` expressions to profiled columns."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, frames: dict[str, DatasetProfile], profiles: list[DatasetProfile]):
|
|
77
|
+
self.frames = frames # variable name -> profile
|
|
78
|
+
self.profiles = profiles # every known profile (fallback lookup by column name)
|
|
79
|
+
self.aliases: dict[str, ColumnRef] = {} # treated = df[df.g == 'a']['score']
|
|
80
|
+
|
|
81
|
+
def learn_aliases(self, tree: ast.AST) -> None:
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
84
|
+
ref = self.ref(node.value)
|
|
85
|
+
if ref:
|
|
86
|
+
self.aliases[node.targets[0].id] = ref
|
|
87
|
+
|
|
88
|
+
def _lookup(self, frame: str | None, column: str) -> ColumnRef | None:
|
|
89
|
+
if frame and frame in self.frames:
|
|
90
|
+
prof = self.frames[frame]
|
|
91
|
+
info = prof.get(column)
|
|
92
|
+
return ColumnRef(frame, column, prof, info) if info else None
|
|
93
|
+
for prof in self.profiles:
|
|
94
|
+
info = prof.get(column)
|
|
95
|
+
if info:
|
|
96
|
+
return ColumnRef(frame, column, prof, info)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def ref(self, node: ast.AST) -> ColumnRef | None:
|
|
100
|
+
if isinstance(node, ast.Name) and node.id in self.aliases:
|
|
101
|
+
return self.aliases[node.id]
|
|
102
|
+
if isinstance(node, ast.Subscript):
|
|
103
|
+
base = frame_base(node.value)
|
|
104
|
+
col = const_value(node.slice)
|
|
105
|
+
if base is not None and isinstance(col, str):
|
|
106
|
+
return self._lookup(base.id, col)
|
|
107
|
+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
|
|
108
|
+
name = node.value.id
|
|
109
|
+
if name in self.frames or (not self.frames and self.profiles):
|
|
110
|
+
return self._lookup(name, node.attr)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def refs_in(self, node: ast.AST) -> list[ColumnRef]:
|
|
114
|
+
out, seen = [], set()
|
|
115
|
+
for sub in ast.walk(node):
|
|
116
|
+
r = self.ref(sub)
|
|
117
|
+
if r and (r.frame, r.column) not in seen:
|
|
118
|
+
seen.add((r.frame, r.column))
|
|
119
|
+
out.append(r)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
def first_ref(self, node: ast.AST) -> ColumnRef | None:
|
|
123
|
+
refs = self.refs_in(node)
|
|
124
|
+
return refs[0] if refs else None
|
|
125
|
+
|
|
126
|
+
def is_known_frame(self, name: str) -> bool:
|
|
127
|
+
return name in self.frames
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _src(code: str, node: ast.AST) -> str:
|
|
131
|
+
return (ast.get_source_segment(code, node) or "").strip()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _calls(tree: ast.AST) -> Iterator[ast.Call]:
|
|
135
|
+
for node in ast.walk(tree):
|
|
136
|
+
if isinstance(node, ast.Call):
|
|
137
|
+
yield node
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _expr_text(ref: ColumnRef) -> str:
|
|
141
|
+
return f"{ref.frame or 'df'}['{ref.column}']"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Rules. Each takes (call, resolver, code) and returns a Suggestion or None.
|
|
146
|
+
|
|
147
|
+
Rule = Callable[[ast.Call, Resolver, str], "Suggestion | None"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def rule_pie_many_categories(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
151
|
+
name = call_name(call)
|
|
152
|
+
is_pie = name == "pie" or (name == "plot" and const_value(kwarg(call, "kind")) == "pie")
|
|
153
|
+
if not is_pie:
|
|
154
|
+
return None
|
|
155
|
+
ref = r.first_ref(call)
|
|
156
|
+
if not ref or ref.info.kind not in ("categorical", "boolean", "text", "numeric"):
|
|
157
|
+
return None
|
|
158
|
+
k = ref.info.n_unique
|
|
159
|
+
if k <= PIE_MAX_CATEGORIES:
|
|
160
|
+
return None
|
|
161
|
+
e = _expr_text(ref)
|
|
162
|
+
return Suggestion(
|
|
163
|
+
rule="pie_many_categories",
|
|
164
|
+
concept="part_to_whole_charts",
|
|
165
|
+
title=f"Pie chart with {k} categories",
|
|
166
|
+
problem=f"`{ref.column}` has {k} categories; slices this numerous are hard to compare by angle.",
|
|
167
|
+
alternative="A horizontal bar chart of the counts, sorted by size.",
|
|
168
|
+
rationale=(
|
|
169
|
+
"Bars share a common baseline, so readers compare lengths, which is far more accurate than "
|
|
170
|
+
f"comparing angles. Pies only work for about {PIE_MAX_CATEGORIES} or fewer parts of one whole."
|
|
171
|
+
),
|
|
172
|
+
code=(
|
|
173
|
+
f"counts = {e}.value_counts()\n"
|
|
174
|
+
"ax = counts.sort_values().plot.barh()\n"
|
|
175
|
+
f"ax.set_xlabel('Count'); ax.set_title('{ref.column}')"
|
|
176
|
+
),
|
|
177
|
+
severity="warning",
|
|
178
|
+
columns=[ref.column],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rule_mean_of_skewed(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
183
|
+
if call_name(call) != "mean" or not isinstance(call.func, ast.Attribute):
|
|
184
|
+
return None
|
|
185
|
+
ref = r.ref(call.func.value)
|
|
186
|
+
if not ref or ref.info.kind != "numeric":
|
|
187
|
+
return None
|
|
188
|
+
skew = ref.info.skew
|
|
189
|
+
if skew is None or abs(skew) < SKEW_THRESHOLD:
|
|
190
|
+
return None
|
|
191
|
+
s = ref.info.summary
|
|
192
|
+
e = _expr_text(ref)
|
|
193
|
+
return Suggestion(
|
|
194
|
+
rule="mean_of_skewed",
|
|
195
|
+
concept="center_of_skewed_data",
|
|
196
|
+
title=f"Mean of a skewed variable (`{ref.column}`)",
|
|
197
|
+
problem=(
|
|
198
|
+
f"`{ref.column}` is skewed (skew={skew}); its mean ({s.get('mean')}) differs from its "
|
|
199
|
+
f"median ({s.get('median')}) and may not describe a typical value."
|
|
200
|
+
),
|
|
201
|
+
alternative="Report the median and IQR alongside (or instead of) the mean.",
|
|
202
|
+
rationale="The median is not pulled by the long tail, so it represents a typical observation better.",
|
|
203
|
+
code=(
|
|
204
|
+
f"{e}.median()\n"
|
|
205
|
+
f"{e}.quantile([0.25, 0.75]) # IQR\n"
|
|
206
|
+
f"{e}.describe() # mean and quartiles side by side"
|
|
207
|
+
),
|
|
208
|
+
columns=[ref.column],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def rule_ttest_equal_var(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
213
|
+
if call_name(call) != "ttest_ind" or kwarg(call, "equal_var") is not None:
|
|
214
|
+
return None
|
|
215
|
+
args = ", ".join(_src(code, a) for a in call.args) or "a, b"
|
|
216
|
+
return Suggestion(
|
|
217
|
+
rule="ttest_equal_var",
|
|
218
|
+
concept="welch_t_test",
|
|
219
|
+
title="t-test assumes equal variances",
|
|
220
|
+
problem="`ttest_ind` defaults to Student's t-test, which assumes both groups have equal variance.",
|
|
221
|
+
alternative="Welch's t-test: the same function with `equal_var=False`.",
|
|
222
|
+
rationale=(
|
|
223
|
+
"Welch's test stays valid when variances or group sizes differ and costs almost nothing when "
|
|
224
|
+
"they are equal, so it is the safer default."
|
|
225
|
+
),
|
|
226
|
+
code=f"stats.ttest_ind({args}, equal_var=False)",
|
|
227
|
+
severity="warning",
|
|
228
|
+
columns=[x.column for x in r.refs_in(call)],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_SCIPY_TESTS = {
|
|
233
|
+
"ttest_ind", "ttest_rel", "ttest_1samp", "mannwhitneyu", "wilcoxon", "pearsonr", "spearmanr",
|
|
234
|
+
"kendalltau", "f_oneway", "kruskal", "shapiro", "normaltest", "linregress",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def rule_nan_in_scipy(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
239
|
+
name = call_name(call)
|
|
240
|
+
if name not in _SCIPY_TESTS or kwarg(call, "nan_policy") is not None:
|
|
241
|
+
return None
|
|
242
|
+
text = _src(code, call)
|
|
243
|
+
if "dropna" in text:
|
|
244
|
+
return None
|
|
245
|
+
missing = [x for x in r.refs_in(call) if x.info.n_missing > 0]
|
|
246
|
+
if not missing:
|
|
247
|
+
return None
|
|
248
|
+
cols = ", ".join(f"`{x.column}` ({x.info.n_missing} missing)" for x in missing)
|
|
249
|
+
return Suggestion(
|
|
250
|
+
rule="nan_in_scipy",
|
|
251
|
+
concept="missing_values_in_tests",
|
|
252
|
+
title=f"Missing values passed to `{name}`",
|
|
253
|
+
problem=f"{cols}. SciPy may return `nan` or fail instead of skipping them like pandas does.",
|
|
254
|
+
alternative="Handle missing values explicitly - drop them for this test, or use `nan_policy='omit'`.",
|
|
255
|
+
rationale=(
|
|
256
|
+
"Which rows enter a test is an analytical decision. Making it explicit keeps the result "
|
|
257
|
+
"reproducible and lets you report the actual sample size."
|
|
258
|
+
),
|
|
259
|
+
code=(
|
|
260
|
+
f"# option 1: drop missing values for this test only\n"
|
|
261
|
+
f"{_expr_text(missing[0])}.dropna()\n"
|
|
262
|
+
f"# option 2: let SciPy skip them (supported by most scipy.stats tests)\n"
|
|
263
|
+
f"{name}(..., nan_policy='omit')"
|
|
264
|
+
),
|
|
265
|
+
severity="warning",
|
|
266
|
+
columns=[x.column for x in missing],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def rule_pearson_on_skewed(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
271
|
+
name = call_name(call)
|
|
272
|
+
if name == "corr":
|
|
273
|
+
method = const_value(kwarg(call, "method")) or "pearson"
|
|
274
|
+
if method != "pearson":
|
|
275
|
+
return None
|
|
276
|
+
refs = r.refs_in(call)
|
|
277
|
+
if not refs and isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
|
|
278
|
+
prof = r.frames.get(call.func.value.id)
|
|
279
|
+
refs = [ColumnRef(call.func.value.id, c, prof, prof.columns[c]) for c in prof.numeric] if prof else []
|
|
280
|
+
elif name == "pearsonr":
|
|
281
|
+
refs = r.refs_in(call)
|
|
282
|
+
else:
|
|
283
|
+
return None
|
|
284
|
+
skewed = [x for x in refs if x.info.kind == "numeric" and x.info.skew is not None and abs(x.info.skew) >= SKEW_THRESHOLD]
|
|
285
|
+
if not skewed:
|
|
286
|
+
return None
|
|
287
|
+
cols = ", ".join(f"`{x.column}` (skew={x.info.skew})" for x in skewed[:4])
|
|
288
|
+
fix = (
|
|
289
|
+
"stats.spearmanr(x, y)" if name == "pearsonr" else _src(code, call).replace("corr()", "corr(method='spearman')")
|
|
290
|
+
)
|
|
291
|
+
if fix == _src(code, call):
|
|
292
|
+
fix = "df.corr(method='spearman', numeric_only=True)"
|
|
293
|
+
return Suggestion(
|
|
294
|
+
rule="pearson_on_skewed",
|
|
295
|
+
concept="correlation_method",
|
|
296
|
+
title="Pearson correlation on skewed data",
|
|
297
|
+
problem=f"Pearson's r is sensitive to skew and outliers, and these variables are skewed: {cols}.",
|
|
298
|
+
alternative="Spearman's rank correlation (or check a scatter plot before trusting Pearson's r).",
|
|
299
|
+
rationale="Spearman works on ranks, so a few extreme values cannot dominate the coefficient.",
|
|
300
|
+
code=fix,
|
|
301
|
+
columns=[x.column for x in skewed],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def rule_categorical_on_numeric_chart(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
306
|
+
name = call_name(call)
|
|
307
|
+
func = dotted_name(call.func) or ""
|
|
308
|
+
if name == "hist":
|
|
309
|
+
ref = r.first_ref(call)
|
|
310
|
+
if ref and ref.info.kind in ("categorical", "boolean", "text"):
|
|
311
|
+
return Suggestion(
|
|
312
|
+
rule="hist_categorical",
|
|
313
|
+
concept="histogram_categorical",
|
|
314
|
+
title=f"Histogram of a categorical column (`{ref.column}`)",
|
|
315
|
+
problem=f"`{ref.column}` is categorical ({ref.info.n_unique} levels); histograms need a numeric scale.",
|
|
316
|
+
alternative="A bar chart of counts per category.",
|
|
317
|
+
rationale="Each category is a separate group, not a range of values that can be binned.",
|
|
318
|
+
code=f"{_expr_text(ref)}.value_counts().plot.bar()",
|
|
319
|
+
columns=[ref.column],
|
|
320
|
+
)
|
|
321
|
+
if name == "plot" and func.endswith("plt.plot") and call.args:
|
|
322
|
+
ref = r.ref(call.args[0])
|
|
323
|
+
if ref and ref.info.kind in ("categorical", "text"):
|
|
324
|
+
y = _src(code, call.args[1]) if len(call.args) > 1 else "df['y']"
|
|
325
|
+
return Suggestion(
|
|
326
|
+
rule="line_categorical_x",
|
|
327
|
+
concept="categorical_axis",
|
|
328
|
+
title=f"Line plot over a categorical x-axis (`{ref.column}`)",
|
|
329
|
+
problem="A line connects neighbouring points, implying an order and trend that categories do not have.",
|
|
330
|
+
alternative="A bar chart of a summary per category, or a boxplot of the distribution per category.",
|
|
331
|
+
rationale=f"`{ref.column}` is nominal ({ref.info.n_unique} levels), so neighbouring categories are not related.",
|
|
332
|
+
code=(
|
|
333
|
+
f"{ref.frame or 'df'}.groupby('{ref.column}')[...].mean().plot.bar()\n"
|
|
334
|
+
f"# or: {ref.frame or 'df'}.boxplot(column=..., by='{ref.column}')"
|
|
335
|
+
),
|
|
336
|
+
columns=[ref.column],
|
|
337
|
+
)
|
|
338
|
+
if name == "scatter":
|
|
339
|
+
refs = [x for x in r.refs_in(call) if x.info.kind in ("categorical", "text")]
|
|
340
|
+
if refs and len(r.refs_in(call)) >= 2:
|
|
341
|
+
c = refs[0]
|
|
342
|
+
return Suggestion(
|
|
343
|
+
rule="scatter_categorical",
|
|
344
|
+
concept="categorical_axis",
|
|
345
|
+
title=f"Scatter plot with a categorical axis (`{c.column}`)",
|
|
346
|
+
problem="Scatter plots assume both axes are numeric; points for a category pile up on one line.",
|
|
347
|
+
alternative="A boxplot (or strip plot) of the numeric variable for each category.",
|
|
348
|
+
rationale="Boxplots show the centre, spread and outliers of each group side by side.",
|
|
349
|
+
code=f"{c.frame or 'df'}.boxplot(column=<numeric column>, by='{c.column}')",
|
|
350
|
+
columns=[c.column],
|
|
351
|
+
)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def rule_global_dropna(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
356
|
+
if call_name(call) != "dropna" or not isinstance(call.func, ast.Attribute):
|
|
357
|
+
return None
|
|
358
|
+
if call.args or kwarg(call, "subset") is not None or kwarg(call, "axis") is not None:
|
|
359
|
+
return None
|
|
360
|
+
target = call.func.value
|
|
361
|
+
if not isinstance(target, ast.Name) or not r.is_known_frame(target.id):
|
|
362
|
+
return None
|
|
363
|
+
prof = r.frames[target.id]
|
|
364
|
+
lost = prof.n_rows - prof.n_complete_rows
|
|
365
|
+
if prof.n_rows == 0 or lost / prof.n_rows < 0.05:
|
|
366
|
+
return None
|
|
367
|
+
worst = sorted(prof.columns.values(), key=lambda c: -c.n_missing)[:3]
|
|
368
|
+
worst_s = ", ".join(f"`{c.name}` ({c.n_missing})" for c in worst if c.n_missing)
|
|
369
|
+
return Suggestion(
|
|
370
|
+
rule="global_dropna",
|
|
371
|
+
concept="listwise_deletion",
|
|
372
|
+
title=f"`{target.id}.dropna()` removes {lost} of {prof.n_rows} rows",
|
|
373
|
+
problem=(
|
|
374
|
+
f"Dropping every row with any missing value loses {100 * lost / prof.n_rows:.0f}% of the data; "
|
|
375
|
+
f"most missing values come from {worst_s}."
|
|
376
|
+
),
|
|
377
|
+
alternative="Drop only on the columns your analysis uses, and keep the original DataFrame.",
|
|
378
|
+
rationale="Listwise deletion can shrink and bias the sample; restricting it keeps rows you still need.",
|
|
379
|
+
code=f"clean = {target.id}.dropna(subset=[...]) # the columns used in this analysis\nprint(len({target.id}), '->', len(clean))",
|
|
380
|
+
severity="warning",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def rule_text_numbers(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
|
|
385
|
+
if call_name(call) not in {"mean", "sum", "std", "median", "hist", "corr", "ttest_ind", "pearsonr"}:
|
|
386
|
+
return None
|
|
387
|
+
for ref in r.refs_in(call):
|
|
388
|
+
if ref.info.kind in ("categorical", "text") and any("looks numeric" in n for n in ref.info.notes):
|
|
389
|
+
e = _expr_text(ref)
|
|
390
|
+
return Suggestion(
|
|
391
|
+
rule="text_numbers",
|
|
392
|
+
concept="text_numbers",
|
|
393
|
+
title=f"`{ref.column}` is numbers stored as text",
|
|
394
|
+
problem=f"`{ref.column}` has dtype {ref.info.dtype}; numeric operations will fail or misbehave.",
|
|
395
|
+
alternative="Convert it explicitly with `pd.to_numeric`, after checking which values do not parse.",
|
|
396
|
+
rationale="Seeing the bad values first stops you silently turning real data into NaN.",
|
|
397
|
+
code=(
|
|
398
|
+
f"converted = pd.to_numeric({e}, errors='coerce')\n"
|
|
399
|
+
f"print({e}[converted.isna() & {e}.notna()].unique()) # values that failed\n"
|
|
400
|
+
f"{ref.frame or 'df'}['{ref.column}_num'] = converted"
|
|
401
|
+
),
|
|
402
|
+
severity="warning",
|
|
403
|
+
columns=[ref.column],
|
|
404
|
+
)
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
RULES: list[Rule] = [
|
|
409
|
+
rule_pie_many_categories,
|
|
410
|
+
rule_mean_of_skewed,
|
|
411
|
+
rule_ttest_equal_var,
|
|
412
|
+
rule_nan_in_scipy,
|
|
413
|
+
rule_pearson_on_skewed,
|
|
414
|
+
rule_categorical_on_numeric_chart,
|
|
415
|
+
rule_global_dropna,
|
|
416
|
+
rule_text_numbers,
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# Column-name checks (catch KeyErrors before running)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def unknown_columns(tree: ast.AST, r: Resolver, code: str) -> list[Suggestion]:
|
|
425
|
+
out = []
|
|
426
|
+
for node in ast.walk(tree):
|
|
427
|
+
if not isinstance(node, ast.Subscript):
|
|
428
|
+
continue
|
|
429
|
+
# df['x'] or a row-filtered frame such as df[df['g'] == 'a']['x']
|
|
430
|
+
base = frame_base(node.value)
|
|
431
|
+
if base is None:
|
|
432
|
+
continue
|
|
433
|
+
prof = r.frames.get(base.id)
|
|
434
|
+
col = const_value(node.slice)
|
|
435
|
+
if not prof or not isinstance(col, str) or col in prof.columns:
|
|
436
|
+
continue
|
|
437
|
+
# assignment creates a new column: df['new'] = ...
|
|
438
|
+
if isinstance(node.ctx, ast.Store):
|
|
439
|
+
continue
|
|
440
|
+
match = difflib.get_close_matches(col, list(prof.columns), n=1, cutoff=0.6)
|
|
441
|
+
lowered = {c.lower().strip(): c for c in prof.columns}
|
|
442
|
+
match = [lowered[col.lower().strip()]] if col.lower().strip() in lowered else match
|
|
443
|
+
hint = f"Did you mean `{match[0]}`?" if match else f"Available columns: {', '.join(list(prof.columns)[:12])}"
|
|
444
|
+
out.append(
|
|
445
|
+
Suggestion(
|
|
446
|
+
rule="unknown_column",
|
|
447
|
+
concept="column_names",
|
|
448
|
+
title=f"`{base.id}` has no column `{col}`",
|
|
449
|
+
problem=f"This will raise a KeyError. {hint}",
|
|
450
|
+
alternative=f"Use the exact column name{f' `{match[0]}`' if match else ''}.",
|
|
451
|
+
rationale="Column labels are exact strings - case, spaces and underscores matter.",
|
|
452
|
+
code=f"{base.id}['{match[0]}']" if match else f"print(list({base.id}.columns))",
|
|
453
|
+
line=getattr(node, "lineno", None),
|
|
454
|
+
severity="warning",
|
|
455
|
+
columns=[col],
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class CodeAdvisor:
|
|
465
|
+
def __init__(self, rules: list[Rule] | None = None):
|
|
466
|
+
self.rules = rules if rules is not None else list(RULES)
|
|
467
|
+
|
|
468
|
+
def check(
|
|
469
|
+
self,
|
|
470
|
+
code: str,
|
|
471
|
+
frames: dict[str, DatasetProfile] | None = None,
|
|
472
|
+
profiles: list[DatasetProfile] | None = None,
|
|
473
|
+
) -> list[Suggestion]:
|
|
474
|
+
"""Return suggestions for ``code``. Returns [] for code that does not parse."""
|
|
475
|
+
try:
|
|
476
|
+
tree = ast.parse(code)
|
|
477
|
+
except SyntaxError:
|
|
478
|
+
return []
|
|
479
|
+
frames = frames or {}
|
|
480
|
+
profiles = profiles if profiles is not None else list(frames.values())
|
|
481
|
+
resolver = Resolver(frames, profiles)
|
|
482
|
+
resolver.learn_aliases(tree)
|
|
483
|
+
out: list[Suggestion] = []
|
|
484
|
+
lines = code.splitlines()
|
|
485
|
+
for call in _calls(tree):
|
|
486
|
+
for rule in self.rules:
|
|
487
|
+
try:
|
|
488
|
+
s = rule(call, resolver, code)
|
|
489
|
+
except Exception: # a broken rule must never break the user's workflow
|
|
490
|
+
s = None
|
|
491
|
+
if s:
|
|
492
|
+
s.line = s.line or call.lineno
|
|
493
|
+
s.source_code = lines[call.lineno - 1].strip() if call.lineno <= len(lines) else ""
|
|
494
|
+
out.append(s)
|
|
495
|
+
for s in unknown_columns(tree, resolver, code):
|
|
496
|
+
s.source_code = lines[s.line - 1].strip() if s.line and s.line <= len(lines) else ""
|
|
497
|
+
out.append(s)
|
|
498
|
+
# one suggestion per (rule, line): nested calls can trigger a rule twice
|
|
499
|
+
unique: dict[tuple[str, int | None], Suggestion] = {}
|
|
500
|
+
for s in out:
|
|
501
|
+
unique.setdefault((s.rule, s.line), s)
|
|
502
|
+
return sorted(unique.values(), key=lambda s: (s.line or 0, s.severity != "warning"))
|