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/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 "***"
@@ -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