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/config.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""learnstat's own AI settings: saved profiles of provider, API key, endpoint and model.
|
|
2
|
+
|
|
3
|
+
Run ``learnstat setup`` for a guided choice of company, key and model. Several
|
|
4
|
+
profiles can be saved (one per company / key) and switched between::
|
|
5
|
+
|
|
6
|
+
learnstat setup # guided wizard: company -> key -> model -> check -> start
|
|
7
|
+
learnstat config list # saved profiles
|
|
8
|
+
learnstat config use anthropic # switch the active profile
|
|
9
|
+
learnstat config set model gpt-4o # edit the active profile
|
|
10
|
+
|
|
11
|
+
Environment variables (``LEARNSTAT_API_KEY``, ``LEARNSTAT_BASE_URL``,
|
|
12
|
+
``LEARNSTAT_MODEL``) override the active profile for one run.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import stat
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import providers
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
26
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
27
|
+
|
|
28
|
+
# setting name -> environment variable
|
|
29
|
+
ENV_VARS = {
|
|
30
|
+
"api_key": "LEARNSTAT_API_KEY",
|
|
31
|
+
"base_url": "LEARNSTAT_BASE_URL",
|
|
32
|
+
"model": "LEARNSTAT_MODEL",
|
|
33
|
+
}
|
|
34
|
+
KEYS = tuple(ENV_VARS)
|
|
35
|
+
_PROFILE_FIELDS = ("provider", "base_url", "api_key", "model")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
"""Location of the config file (``LEARNSTAT_CONFIG`` overrides the default)."""
|
|
40
|
+
override = os.environ.get("LEARNSTAT_CONFIG")
|
|
41
|
+
if override:
|
|
42
|
+
return Path(override).expanduser()
|
|
43
|
+
return Path.home() / ".learnstat" / "config.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# storage
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read() -> dict:
|
|
51
|
+
"""Load the file as ``{"active": str|None, "profiles": {...}, "skip_setup": bool}``."""
|
|
52
|
+
try:
|
|
53
|
+
raw = json.loads(config_path().read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
raw = {}
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
raw = {}
|
|
58
|
+
profiles = {
|
|
59
|
+
name: {k: str(v) for k, v in p.items() if k in _PROFILE_FIELDS and v not in (None, "")}
|
|
60
|
+
for name, p in (raw.get("profiles") or {}).items()
|
|
61
|
+
if isinstance(p, dict)
|
|
62
|
+
}
|
|
63
|
+
active = raw.get("active") if raw.get("active") in profiles else None
|
|
64
|
+
# an older flat file ({"api_key": ..., "model": ...}) becomes a "custom" profile
|
|
65
|
+
flat = {k: str(raw[k]) for k in KEYS if raw.get(k)}
|
|
66
|
+
if flat and not profiles:
|
|
67
|
+
profiles["custom"] = {**flat, "provider": "custom"}
|
|
68
|
+
active = "custom"
|
|
69
|
+
if active is None and profiles:
|
|
70
|
+
active = next(iter(profiles))
|
|
71
|
+
return {"active": active, "profiles": profiles, "skip_setup": bool(raw.get("skip_setup"))}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write(data: dict) -> Path:
|
|
75
|
+
path = config_path()
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
78
|
+
try: # the file holds API keys: keep it private where the OS supports it
|
|
79
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profiles() -> dict[str, dict]:
|
|
86
|
+
return _read()["profiles"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def active_name() -> str | None:
|
|
90
|
+
return _read()["active"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_profile(name: str, provider: str, base_url: str, api_key: str, model: str, activate: bool = True) -> Path:
|
|
94
|
+
data = _read()
|
|
95
|
+
data["profiles"][name] = {k: v for k, v in
|
|
96
|
+
{"provider": provider, "base_url": base_url, "api_key": api_key, "model": model}.items() if v}
|
|
97
|
+
if activate or data["active"] is None:
|
|
98
|
+
data["active"] = name
|
|
99
|
+
return _write(data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def set_active(name: str) -> Path:
|
|
103
|
+
data = _read()
|
|
104
|
+
if name not in data["profiles"]:
|
|
105
|
+
raise KeyError(f"no saved profile {name!r}; saved: {', '.join(data['profiles']) or 'none'}")
|
|
106
|
+
data["active"] = name
|
|
107
|
+
return _write(data)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def remove_profile(name: str) -> Path:
|
|
111
|
+
data = _read()
|
|
112
|
+
if name not in data["profiles"]:
|
|
113
|
+
raise KeyError(f"no saved profile {name!r}; saved: {', '.join(data['profiles']) or 'none'}")
|
|
114
|
+
del data["profiles"][name]
|
|
115
|
+
if data["active"] == name:
|
|
116
|
+
data["active"] = next(iter(data["profiles"]), None)
|
|
117
|
+
return _write(data)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def set_skip_setup(skip: bool) -> Path:
|
|
121
|
+
data = _read()
|
|
122
|
+
data["skip_setup"] = skip
|
|
123
|
+
return _write(data)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def skip_setup() -> bool:
|
|
127
|
+
return _read()["skip_setup"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_value(key: str, value: str) -> Path:
|
|
131
|
+
"""Edit one setting of the active profile (a ``custom`` profile is created if none exists)."""
|
|
132
|
+
if key not in KEYS:
|
|
133
|
+
raise KeyError(f"unknown setting {key!r}; choose from {', '.join(KEYS)}")
|
|
134
|
+
data = _read()
|
|
135
|
+
if data["active"] is None:
|
|
136
|
+
data["profiles"]["custom"] = {"provider": "custom"}
|
|
137
|
+
data["active"] = "custom"
|
|
138
|
+
data["profiles"][data["active"]][key] = value.strip()
|
|
139
|
+
return _write(data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def unset_value(key: str) -> Path:
|
|
143
|
+
if key not in KEYS:
|
|
144
|
+
raise KeyError(f"unknown setting {key!r}; choose from {', '.join(KEYS)}")
|
|
145
|
+
data = _read()
|
|
146
|
+
if data["active"] is not None:
|
|
147
|
+
data["profiles"][data["active"]].pop(key, None)
|
|
148
|
+
return _write(data)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# resolved settings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class AIConfig:
|
|
157
|
+
api_key: str
|
|
158
|
+
base_url: str
|
|
159
|
+
model: str
|
|
160
|
+
provider: str = "custom"
|
|
161
|
+
profile: str | None = None
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def chat_url(self) -> str:
|
|
165
|
+
base = self.base_url.rstrip("/")
|
|
166
|
+
return base if base.endswith("/chat/completions") else base + "/chat/completions"
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def models_url(self) -> str:
|
|
170
|
+
base = self.base_url.rstrip("/")
|
|
171
|
+
if base.endswith("/chat/completions"):
|
|
172
|
+
base = base[: -len("/chat/completions")]
|
|
173
|
+
return base + "/models"
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def provider_name(self) -> str:
|
|
177
|
+
p = providers.get(self.provider)
|
|
178
|
+
return p.name.split(" (")[0] if p and p.id != "custom" else (self.profile or "custom endpoint")
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def needs_key(self) -> bool:
|
|
182
|
+
"""Hosted endpoints need a key; local ones (Ollama, LM Studio) and custom URLs may not."""
|
|
183
|
+
p = providers.get(self.provider)
|
|
184
|
+
if p is not None and p.id != "custom":
|
|
185
|
+
return p.needs_key
|
|
186
|
+
return self.base_url.rstrip("/") == DEFAULT_BASE_URL
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def headers(self) -> dict[str, str]:
|
|
190
|
+
h = {"Content-Type": "application/json"}
|
|
191
|
+
if self.api_key:
|
|
192
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
193
|
+
if self.provider == "anthropic": # its native auth headers (needed for /models)
|
|
194
|
+
h["x-api-key"] = self.api_key
|
|
195
|
+
h["anthropic-version"] = "2023-06-01"
|
|
196
|
+
return h
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def is_configured(self) -> bool:
|
|
200
|
+
return bool(self.api_key) or not self.needs_key
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve() -> AIConfig:
|
|
204
|
+
data = _read()
|
|
205
|
+
prof = data["profiles"].get(data["active"], {}) if data["active"] else {}
|
|
206
|
+
|
|
207
|
+
def pick(key: str, default: str = "") -> str:
|
|
208
|
+
return os.environ.get(ENV_VARS[key], "").strip() or prof.get(key, "") or default
|
|
209
|
+
|
|
210
|
+
preset = providers.get(prof.get("provider"))
|
|
211
|
+
return AIConfig(
|
|
212
|
+
api_key=pick("api_key"),
|
|
213
|
+
base_url=pick("base_url", preset.base_url if preset and preset.base_url else DEFAULT_BASE_URL),
|
|
214
|
+
model=pick("model", DEFAULT_MODEL),
|
|
215
|
+
provider=prof.get("provider", "custom"),
|
|
216
|
+
profile=data["active"],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def mask(secret: str) -> str:
|
|
221
|
+
if not secret:
|
|
222
|
+
return "(not set)"
|
|
223
|
+
return secret[:3] + "..." + secret[-4:] if len(secret) > 10 else "***"
|
learnstat/ipython_ext.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Jupyter / IPython integration: ``%load_ext learnstat``.
|
|
2
|
+
|
|
3
|
+
* Before each cell runs, its code is checked against the dataset profiles and
|
|
4
|
+
suggestions are printed (the cell still runs exactly as written).
|
|
5
|
+
* A cell with a syntax error gets a diagnosis and, where possible, a corrected
|
|
6
|
+
version placed in a new cell for the user to review and run.
|
|
7
|
+
* When a cell raises, the failing statement is diagnosed and - for mechanical
|
|
8
|
+
mistakes - corrected and retried; statements that already ran are not re-run.
|
|
9
|
+
* Lines starting with ``/describe``, ``/fix``, ``/memory`` ... are learnstat commands.
|
|
10
|
+
* ``/accept`` places the suggested code in a new cell rather than editing yours.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import traceback
|
|
17
|
+
|
|
18
|
+
from .commands import is_command
|
|
19
|
+
from .recovery import _fix_syntax, diagnose
|
|
20
|
+
from .session import Session, set_session
|
|
21
|
+
|
|
22
|
+
_state: dict = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _confirm(question: str) -> bool:
|
|
26
|
+
try:
|
|
27
|
+
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
|
|
28
|
+
except (EOFError, OSError):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_ipython_extension(ip) -> None:
|
|
33
|
+
if "session" in _state:
|
|
34
|
+
return
|
|
35
|
+
session = Session(namespace=ip.user_ns, confirm=_confirm)
|
|
36
|
+
session.on_accept = lambda code: ip.set_next_input(code, replace=False)
|
|
37
|
+
set_session(session)
|
|
38
|
+
ip.learnstat_session = session
|
|
39
|
+
_state["session"] = session
|
|
40
|
+
|
|
41
|
+
def command_transformer(lines: list[str]) -> list[str]:
|
|
42
|
+
if lines and is_command(lines[0]):
|
|
43
|
+
text = "".join(lines).strip()
|
|
44
|
+
return [f"print(get_ipython().learnstat_session.command({text!r}))\n"]
|
|
45
|
+
return lines
|
|
46
|
+
|
|
47
|
+
def pre_run_cell(info) -> None:
|
|
48
|
+
raw = getattr(info, "raw_cell", "") or ""
|
|
49
|
+
if not raw.strip() or is_command(raw.splitlines()[0]):
|
|
50
|
+
_state["cell"] = None
|
|
51
|
+
return
|
|
52
|
+
code = ip.transform_cell(raw)
|
|
53
|
+
_state["cell"] = code
|
|
54
|
+
try:
|
|
55
|
+
ast.parse(code)
|
|
56
|
+
except SyntaxError as e:
|
|
57
|
+
d = diagnose(e, code, e.lineno, ip.user_ns)
|
|
58
|
+
print(f"[learnstat] {d.exc_type} ({d.label}): {d.message}\n {d.explanation}")
|
|
59
|
+
fix = _fix_syntax(e, raw)
|
|
60
|
+
if fix:
|
|
61
|
+
try:
|
|
62
|
+
ast.parse(ip.transform_cell(fix.fixed))
|
|
63
|
+
print(f" Proposed fix: {fix.description}. A corrected copy was placed in the next cell.")
|
|
64
|
+
ip.set_next_input(fix.fixed, replace=False)
|
|
65
|
+
except SyntaxError:
|
|
66
|
+
print(f" Next step: {d.next_step}")
|
|
67
|
+
else:
|
|
68
|
+
print(f" Next step: {d.next_step}")
|
|
69
|
+
return
|
|
70
|
+
session.check(code)
|
|
71
|
+
|
|
72
|
+
def on_exception(shell, etype, evalue, tb, tb_offset=None):
|
|
73
|
+
code = _state.get("cell")
|
|
74
|
+
frames = traceback.extract_tb(tb)
|
|
75
|
+
cell_frame = next((f for f in frames if f.name == "<module>"), None)
|
|
76
|
+
if not code or cell_frame is None:
|
|
77
|
+
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
|
|
78
|
+
return None
|
|
79
|
+
print(f"[learnstat] {etype.__name__}: {evalue}")
|
|
80
|
+
result = session.executor(filename=cell_frame.filename).run(
|
|
81
|
+
code, start_line=cell_frame.lineno, initial_exc=evalue
|
|
82
|
+
)
|
|
83
|
+
session._after_run(code, result)
|
|
84
|
+
if result.ok and result.value is not None:
|
|
85
|
+
shell.displayhook(result.value)
|
|
86
|
+
elif not result.ok and not result.attempts:
|
|
87
|
+
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
ip.input_transformers_cleanup.append(command_transformer)
|
|
91
|
+
ip.events.register("pre_run_cell", pre_run_cell)
|
|
92
|
+
ip.set_custom_exc((Exception,), on_exception)
|
|
93
|
+
_state.update(transformer=command_transformer, pre=pre_run_cell)
|
|
94
|
+
print(f"[learnstat] active. AI: {session.ai_status}. Load data with learnstat.read_csv(...); type /help for commands.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unload_ipython_extension(ip) -> None:
|
|
98
|
+
if "session" not in _state:
|
|
99
|
+
return
|
|
100
|
+
if _state["transformer"] in ip.input_transformers_cleanup:
|
|
101
|
+
ip.input_transformers_cleanup.remove(_state["transformer"])
|
|
102
|
+
ip.events.unregister("pre_run_cell", _state["pre"])
|
|
103
|
+
ip.set_custom_exc((), None)
|
|
104
|
+
_state.clear()
|
learnstat/knowledge.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Offline explanations used by /describe, /alternatives and /learn.
|
|
2
|
+
|
|
3
|
+
Each concept explains the statistical idea, the purpose of the suggested
|
|
4
|
+
function, when it is appropriate and when something else would be better.
|
|
5
|
+
When the AI backend is available these are used as grounding and then
|
|
6
|
+
tailored to the user's dataset; without AI they are shown as-is, filled in with
|
|
7
|
+
the dataset facts recorded on the suggestion.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Concept:
|
|
17
|
+
title: str
|
|
18
|
+
concept: str
|
|
19
|
+
purpose: str
|
|
20
|
+
appropriate_when: str
|
|
21
|
+
alternatives: list[str] = field(default_factory=list)
|
|
22
|
+
lesson: str = ""
|
|
23
|
+
docs: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CONCEPTS: dict[str, Concept] = {
|
|
27
|
+
"part_to_whole_charts": Concept(
|
|
28
|
+
title="Pie charts vs bar charts",
|
|
29
|
+
concept=(
|
|
30
|
+
"People judge angles and areas much less accurately than lengths along a common axis. "
|
|
31
|
+
"A pie chart asks the reader to compare slice angles; with more than about five slices, or "
|
|
32
|
+
"slices of similar size, the comparison becomes guesswork."
|
|
33
|
+
),
|
|
34
|
+
purpose=(
|
|
35
|
+
"`Series.value_counts().plot.barh()` draws one bar per category on a shared baseline, so "
|
|
36
|
+
"differences in length are easy to compare and long labels stay readable."
|
|
37
|
+
),
|
|
38
|
+
appropriate_when=(
|
|
39
|
+
"Use a bar chart whenever the goal is to compare categories. A pie chart is reasonable only "
|
|
40
|
+
"when there are 2-5 parts of a single whole and the message is 'one part dominates'."
|
|
41
|
+
),
|
|
42
|
+
alternatives=[
|
|
43
|
+
"Horizontal bar chart sorted by count: `s.value_counts().plot.barh()`",
|
|
44
|
+
"Group rare levels into 'Other' first: `s.where(s.isin(s.value_counts().index[:8]), 'Other')`",
|
|
45
|
+
"A dot plot (`plt.plot(counts.values, counts.index, 'o')`) for a lighter look",
|
|
46
|
+
"A stacked 100% bar when comparing proportions across several groups",
|
|
47
|
+
],
|
|
48
|
+
lesson=(
|
|
49
|
+
"Exercise: plot the same `value_counts()` as a pie and as a sorted horizontal bar. Try to "
|
|
50
|
+
"rank the 3rd-6th categories from each chart - notice which one you can do without reading labels."
|
|
51
|
+
),
|
|
52
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.Series.plot.barh.html"],
|
|
53
|
+
),
|
|
54
|
+
"center_of_skewed_data": Concept(
|
|
55
|
+
title="Mean vs median for skewed data",
|
|
56
|
+
concept=(
|
|
57
|
+
"The mean is pulled toward long tails and extreme values; the median (the 50th percentile) "
|
|
58
|
+
"is not. In a right-skewed variable such as income, the mean can sit far above what a "
|
|
59
|
+
"typical observation looks like."
|
|
60
|
+
),
|
|
61
|
+
purpose=(
|
|
62
|
+
"`Series.median()` and the IQR (`quantile(0.75) - quantile(0.25)`) summarise the centre and "
|
|
63
|
+
"spread robustly. `describe()` shows both mean and quartiles so you can compare them."
|
|
64
|
+
),
|
|
65
|
+
appropriate_when=(
|
|
66
|
+
"Report the median/IQR when |skew| is about 1 or more, or when there are outliers. The mean is "
|
|
67
|
+
"still the right choice when you need totals (mean x n), or for roughly symmetric data."
|
|
68
|
+
),
|
|
69
|
+
alternatives=[
|
|
70
|
+
"`df[col].describe()` to see mean and quartiles together",
|
|
71
|
+
"A trimmed mean: `scipy.stats.trim_mean(x, 0.1)`",
|
|
72
|
+
"Analyse `np.log1p(x)` if the variable is positive and multiplicative in nature",
|
|
73
|
+
],
|
|
74
|
+
lesson="Exercise: compute mean and median before and after adding one huge value to the column.",
|
|
75
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.Series.median.html"],
|
|
76
|
+
),
|
|
77
|
+
"welch_t_test": Concept(
|
|
78
|
+
title="Student's vs Welch's t-test",
|
|
79
|
+
concept=(
|
|
80
|
+
"`scipy.stats.ttest_ind` assumes equal variances by default (Student's t-test). When group "
|
|
81
|
+
"variances or sizes differ, that assumption inflates the false-positive rate. Welch's test "
|
|
82
|
+
"drops the assumption and loses almost nothing when variances are in fact equal."
|
|
83
|
+
),
|
|
84
|
+
purpose="`ttest_ind(a, b, equal_var=False)` runs Welch's t-test on the same data.",
|
|
85
|
+
appropriate_when=(
|
|
86
|
+
"Welch's is a sensible default for comparing two independent means. For strongly skewed data "
|
|
87
|
+
"or small samples, a rank-based test (Mann-Whitney U) may be preferable; for paired measurements "
|
|
88
|
+
"use `ttest_rel`."
|
|
89
|
+
),
|
|
90
|
+
alternatives=[
|
|
91
|
+
"`scipy.stats.mannwhitneyu(a, b)` - compares distributions without assuming normality",
|
|
92
|
+
"`scipy.stats.ttest_rel(a, b)` - for paired/before-after data",
|
|
93
|
+
"`scipy.stats.f_oneway(*groups)` / `kruskal(*groups)` - for 3+ groups",
|
|
94
|
+
],
|
|
95
|
+
lesson="Exercise: simulate two normal groups with SDs 1 and 4 and sizes 10 and 100; compare p-values.",
|
|
96
|
+
docs=["https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ttest_ind.html"],
|
|
97
|
+
),
|
|
98
|
+
"correlation_method": Concept(
|
|
99
|
+
title="Pearson vs Spearman correlation",
|
|
100
|
+
concept=(
|
|
101
|
+
"Pearson's r measures linear association and is sensitive to outliers and skew. Spearman's rho "
|
|
102
|
+
"is Pearson on ranks: it measures monotonic association and is robust to extreme values."
|
|
103
|
+
),
|
|
104
|
+
purpose="`df.corr(method='spearman')` or `scipy.stats.spearmanr(x, y)`.",
|
|
105
|
+
appropriate_when=(
|
|
106
|
+
"Use Spearman when variables are skewed, ordinal, or contain outliers, or when the relationship "
|
|
107
|
+
"is monotonic but curved. Use Pearson when both variables are roughly symmetric and the "
|
|
108
|
+
"relationship looks linear in a scatter plot."
|
|
109
|
+
),
|
|
110
|
+
alternatives=[
|
|
111
|
+
"Look first: `df.plot.scatter(x=..., y=...)`",
|
|
112
|
+
"Kendall's tau (`method='kendall'`) for small samples with many ties",
|
|
113
|
+
"Pearson on log-transformed variables",
|
|
114
|
+
],
|
|
115
|
+
lesson="Exercise: compute both coefficients for x and x**3 - why does only one equal 1?",
|
|
116
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.corr.html"],
|
|
117
|
+
),
|
|
118
|
+
"missing_values_in_tests": Concept(
|
|
119
|
+
title="Missing values in statistical functions",
|
|
120
|
+
concept=(
|
|
121
|
+
"pandas methods skip NaN by default, but many SciPy functions do not: they return `nan` or "
|
|
122
|
+
"raise an error. How missing values are handled is an analytical decision that changes your "
|
|
123
|
+
"sample, so it should be explicit and reported."
|
|
124
|
+
),
|
|
125
|
+
purpose="`.dropna()` on the series passed to the test, or `nan_policy='omit'` where SciPy supports it.",
|
|
126
|
+
appropriate_when=(
|
|
127
|
+
"Dropping is fine when values are missing at random and few in number. If many are missing or "
|
|
128
|
+
"missingness relates to the outcome, investigate first and consider imputation."
|
|
129
|
+
),
|
|
130
|
+
alternatives=[
|
|
131
|
+
"`nan_policy='omit'` (supported by many scipy.stats functions)",
|
|
132
|
+
"Report n per group after dropping: `a.dropna().size`",
|
|
133
|
+
"Imputation (e.g. median) - only with a documented justification",
|
|
134
|
+
],
|
|
135
|
+
lesson="Exercise: compare `df[col].isna().groupby(df[group]).mean()` across groups - is missingness balanced?",
|
|
136
|
+
docs=["https://docs.scipy.org/doc/scipy/dev/api-dev/nan_policy.html"],
|
|
137
|
+
),
|
|
138
|
+
"categorical_axis": Concept(
|
|
139
|
+
title="Plotting categorical variables",
|
|
140
|
+
concept=(
|
|
141
|
+
"Line plots imply order and continuity between x values; scatter plots and histograms assume "
|
|
142
|
+
"numeric scales. A nominal category (e.g. country) has neither, so these charts can mislead."
|
|
143
|
+
),
|
|
144
|
+
purpose="Bar charts for counts, boxplots/strip plots for a numeric variable across categories.",
|
|
145
|
+
appropriate_when=(
|
|
146
|
+
"Use a line only when x is ordered (time, dose levels). For numeric-by-category comparisons "
|
|
147
|
+
"use `df.boxplot(column=num, by=cat)`."
|
|
148
|
+
),
|
|
149
|
+
alternatives=[
|
|
150
|
+
"`df.boxplot(column='y', by='group')`",
|
|
151
|
+
"`df.groupby('group')['y'].mean().plot.bar()` (show error bars too)",
|
|
152
|
+
"`pd.crosstab(df.a, df.b).plot.bar(stacked=True)` for two categoricals",
|
|
153
|
+
],
|
|
154
|
+
lesson="Exercise: compare a line plot and a bar chart of group means - what does the line falsely suggest?",
|
|
155
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.boxplot.html"],
|
|
156
|
+
),
|
|
157
|
+
"listwise_deletion": Concept(
|
|
158
|
+
title="Dropping rows with missing values",
|
|
159
|
+
concept=(
|
|
160
|
+
"`df.dropna()` removes every row with any missing value (listwise deletion). If missingness is "
|
|
161
|
+
"concentrated in one column you may lose far more data than you need to, and the remaining rows "
|
|
162
|
+
"may no longer represent the population."
|
|
163
|
+
),
|
|
164
|
+
purpose="`df.dropna(subset=[...])` drops rows only where the columns you analyse are missing.",
|
|
165
|
+
appropriate_when=(
|
|
166
|
+
"Drop only on the columns used by a specific analysis, and record how many rows were removed."
|
|
167
|
+
),
|
|
168
|
+
alternatives=[
|
|
169
|
+
"`df.dropna(subset=['col_a', 'col_b'])`",
|
|
170
|
+
"`df.isna().sum()` first, to see where values are missing",
|
|
171
|
+
"Keep the original: `clean = df.dropna(subset=...)` rather than overwriting `df`",
|
|
172
|
+
],
|
|
173
|
+
lesson="Exercise: compare `len(df.dropna())` with `len(df.dropna(subset=[col]))` for each column.",
|
|
174
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.dropna.html"],
|
|
175
|
+
),
|
|
176
|
+
"column_names": Concept(
|
|
177
|
+
title="Selecting columns by name",
|
|
178
|
+
concept="Column labels are exact strings: case, spaces and underscores all matter.",
|
|
179
|
+
purpose="`df.columns` lists the exact names; `df[['a', 'b']]` selects several at once.",
|
|
180
|
+
appropriate_when="Prefer `df['name']` over `df.name` - it works for every label, including ones with spaces.",
|
|
181
|
+
alternatives=["`df.columns = df.columns.str.strip().str.lower()` to normalise names once, explicitly"],
|
|
182
|
+
lesson="Exercise: print `list(df.columns)` and compare it with the names you typed.",
|
|
183
|
+
docs=["https://pandas.pydata.org/docs/user_guide/indexing.html"],
|
|
184
|
+
),
|
|
185
|
+
"text_numbers": Concept(
|
|
186
|
+
title="Numbers stored as text",
|
|
187
|
+
concept=(
|
|
188
|
+
"If a numeric column contains stray characters ('1,200', '$5', 'n/a'), pandas stores it as text "
|
|
189
|
+
"and arithmetic or statistics fail or behave unexpectedly."
|
|
190
|
+
),
|
|
191
|
+
purpose="`pd.to_numeric(s, errors='coerce')` converts what it can and turns the rest into NaN.",
|
|
192
|
+
appropriate_when=(
|
|
193
|
+
"Always inspect which values failed first: `s[pd.to_numeric(s, errors='coerce').isna() & s.notna()]`. "
|
|
194
|
+
"Converting is a data-cleaning decision, so record it."
|
|
195
|
+
),
|
|
196
|
+
alternatives=["`s.str.replace(',', '')` before converting", "`pd.read_csv(..., thousands=',')`"],
|
|
197
|
+
lesson="Exercise: find the rows that fail to convert and decide, case by case, how to treat them.",
|
|
198
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.to_numeric.html"],
|
|
199
|
+
),
|
|
200
|
+
"histogram_categorical": Concept(
|
|
201
|
+
title="Histograms are for numeric variables",
|
|
202
|
+
concept=(
|
|
203
|
+
"A histogram bins a continuous scale. Category labels have no scale, so you need counts per "
|
|
204
|
+
"level instead."
|
|
205
|
+
),
|
|
206
|
+
purpose="`s.value_counts().plot.bar()` shows the count for each level.",
|
|
207
|
+
appropriate_when="Use histograms for numeric columns, bar charts of counts for categorical ones.",
|
|
208
|
+
alternatives=["`s.value_counts(normalize=True)` for proportions"],
|
|
209
|
+
lesson="Exercise: draw the bar chart and sort it by count vs by label - which is easier to read?",
|
|
210
|
+
docs=["https://pandas.pydata.org/docs/reference/api/pandas.Series.value_counts.html"],
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def describe_concept(key: str) -> Concept | None:
|
|
216
|
+
return CONCEPTS.get(key)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def find_topic(query: str) -> Concept | None:
|
|
220
|
+
q = query.lower().strip()
|
|
221
|
+
if not q:
|
|
222
|
+
return None
|
|
223
|
+
for key, c in CONCEPTS.items():
|
|
224
|
+
if q in key or q in c.title.lower():
|
|
225
|
+
return c
|
|
226
|
+
words = set(q.split())
|
|
227
|
+
best, best_score = None, 0
|
|
228
|
+
for c in CONCEPTS.values():
|
|
229
|
+
text = (c.title + " " + c.concept + " " + c.purpose).lower()
|
|
230
|
+
score = sum(1 for w in words if w in text)
|
|
231
|
+
if score > best_score:
|
|
232
|
+
best, best_score = c, score
|
|
233
|
+
return best
|