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/profile.py ADDED
@@ -0,0 +1,344 @@
1
+ """Rule-based dataset profiling.
2
+
3
+ Everything here works without AI. The profile is the analytical context that
4
+ the advisor, the recovery engine and the optional AI backend all share.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ import warnings
12
+ from dataclasses import asdict, dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ # Column-name hints used to infer what a column probably means. These are
20
+ # guesses and are always labelled as such in the Markdown memory.
21
+ _MEANING_HINTS: list[tuple[str, str]] = [
22
+ (r"(^|_)id$|^id_|uuid|identifier", "identifier (row key, not a measurement)"),
23
+ (r"date|time|timestamp|_at$|year|month|day", "date / time"),
24
+ (r"age", "age"),
25
+ (r"price|cost|amount|revenue|sales|income|salary|fare|spend", "monetary amount"),
26
+ (r"gender|sex", "demographic category"),
27
+ (r"country|city|state|region|zip|postcode|location", "geographic location"),
28
+ (r"score|rating|grade", "score / rating"),
29
+ (r"count|qty|quantity|num_|number", "count"),
30
+ (r"weight|height|length|width|size|bmi", "physical measurement"),
31
+ (r"lat(itude)?$|lon(gitude)?$|lng$", "geographic coordinate"),
32
+ (r"pct|percent|rate|ratio", "rate / proportion"),
33
+ (r"^is_|^has_|flag|survived|churn|target|label|outcome", "binary outcome / flag"),
34
+ (r"name|title|description|comment|text|notes", "free text / label"),
35
+ (r"group|category|type|class|segment|treatment", "grouping variable"),
36
+ ]
37
+
38
+
39
+ @dataclass
40
+ class ColumnProfile:
41
+ name: str
42
+ dtype: str
43
+ kind: str # numeric | categorical | boolean | datetime | text | identifier
44
+ n_missing: int
45
+ pct_missing: float
46
+ n_unique: int
47
+ summary: dict[str, Any] = field(default_factory=dict)
48
+ notes: list[str] = field(default_factory=list)
49
+ inferred_meaning: str | None = None
50
+
51
+ @property
52
+ def skew(self) -> float | None:
53
+ return self.summary.get("skew")
54
+
55
+
56
+ @dataclass
57
+ class DatasetProfile:
58
+ name: str
59
+ source: str | None
60
+ n_rows: int
61
+ n_cols: int
62
+ fingerprint: str
63
+ columns: dict[str, ColumnProfile]
64
+ correlations: list[dict[str, Any]] = field(default_factory=list)
65
+ issues: list[str] = field(default_factory=list)
66
+ suggested_analyses: list[str] = field(default_factory=list)
67
+ n_duplicate_rows: int = 0
68
+ n_complete_rows: int = 0
69
+ created_at: str = ""
70
+
71
+ # -- convenience accessors -------------------------------------------
72
+ def columns_of_kind(self, *kinds: str) -> list[str]:
73
+ return [c.name for c in self.columns.values() if c.kind in kinds]
74
+
75
+ @property
76
+ def numeric(self) -> list[str]:
77
+ return self.columns_of_kind("numeric")
78
+
79
+ @property
80
+ def categorical(self) -> list[str]:
81
+ return self.columns_of_kind("categorical", "boolean")
82
+
83
+ def get(self, column: str) -> ColumnProfile | None:
84
+ return self.columns.get(column)
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ d = asdict(self)
88
+ d["columns"] = {k: asdict(v) for k, v in self.columns.items()}
89
+ return d
90
+
91
+ @classmethod
92
+ def from_dict(cls, d: dict[str, Any]) -> "DatasetProfile":
93
+ d = dict(d)
94
+ d["columns"] = {k: ColumnProfile(**v) for k, v in d["columns"].items()}
95
+ return cls(**d)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def fingerprint(df: pd.DataFrame) -> str:
102
+ """A cheap content hash used to decide whether the profile is stale."""
103
+ h = hashlib.sha256()
104
+ h.update(str(df.shape).encode())
105
+ h.update("|".join(f"{c}:{t}" for c, t in df.dtypes.astype(str).items()).encode())
106
+ try:
107
+ h.update(str(int(pd.util.hash_pandas_object(df, index=True).sum())).encode())
108
+ except (TypeError, ValueError): # unhashable cells, e.g. lists
109
+ h.update(df.head(1000).to_csv().encode())
110
+ return h.hexdigest()[:16]
111
+
112
+
113
+ def _infer_meaning(name: str) -> str | None:
114
+ lowered = name.lower()
115
+ for pattern, meaning in _MEANING_HINTS:
116
+ if re.search(pattern, lowered):
117
+ return meaning
118
+ return None
119
+
120
+
121
+ def _to_float(x: Any) -> float | None:
122
+ try:
123
+ if x is None or pd.isna(x):
124
+ return None
125
+ return round(float(x), 4)
126
+ except (TypeError, ValueError):
127
+ return None
128
+
129
+
130
+ def _looks_numeric_text(s: pd.Series) -> float:
131
+ sample = s.dropna().astype(str).head(500)
132
+ if sample.empty:
133
+ return 0.0
134
+ cleaned = sample.str.replace(r"[,\s$%€£]", "", regex=True)
135
+ return float(pd.to_numeric(cleaned, errors="coerce").notna().mean())
136
+
137
+
138
+ def _looks_datetime_text(s: pd.Series) -> float:
139
+ sample = s.dropna().astype(str).head(200)
140
+ if sample.empty or not sample.str.contains(r"\d").all():
141
+ return 0.0
142
+ with warnings.catch_warnings():
143
+ warnings.simplefilter("ignore")
144
+ try:
145
+ parsed = pd.to_datetime(sample, errors="coerce", format="mixed")
146
+ except (TypeError, ValueError):
147
+ return 0.0
148
+ return float(parsed.notna().mean())
149
+
150
+
151
+ def _profile_column(s: pd.Series, n_rows: int) -> ColumnProfile:
152
+ name = str(s.name)
153
+ n_missing = int(s.isna().sum())
154
+ non_null = s.dropna()
155
+ try:
156
+ n_unique = int(non_null.nunique())
157
+ except TypeError:
158
+ n_unique = int(non_null.astype(str).nunique())
159
+ col = ColumnProfile(
160
+ name=name,
161
+ dtype=str(s.dtype),
162
+ kind="text",
163
+ n_missing=n_missing,
164
+ pct_missing=round(100 * n_missing / n_rows, 2) if n_rows else 0.0,
165
+ n_unique=n_unique,
166
+ inferred_meaning=_infer_meaning(name),
167
+ )
168
+
169
+ if pd.api.types.is_bool_dtype(s):
170
+ col.kind = "boolean"
171
+ col.summary = {"true_share": _to_float(non_null.mean())}
172
+ elif pd.api.types.is_datetime64_any_dtype(s):
173
+ col.kind = "datetime"
174
+ col.summary = {"min": str(non_null.min()), "max": str(non_null.max())}
175
+ elif pd.api.types.is_numeric_dtype(s):
176
+ col.kind = "numeric"
177
+ x = non_null.astype(float)
178
+ q1, q3 = (x.quantile(0.25), x.quantile(0.75)) if len(x) else (np.nan, np.nan)
179
+ iqr = q3 - q1
180
+ n_outliers = int(((x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)).sum()) if len(x) else 0
181
+ col.summary = {
182
+ "mean": _to_float(x.mean()),
183
+ "std": _to_float(x.std()),
184
+ "min": _to_float(x.min()),
185
+ "median": _to_float(x.median()),
186
+ "max": _to_float(x.max()),
187
+ "skew": _to_float(x.skew()) if len(x) > 2 else None,
188
+ "n_outliers_iqr": n_outliers,
189
+ }
190
+ name_l = name.lower()
191
+ if n_unique == n_rows and n_rows > 20 and re.search(r"(^|_)id$|^id_|index", name_l):
192
+ col.kind = "identifier"
193
+ col.notes.append("Unique integer per row - treat as an identifier, not a measurement.")
194
+ elif n_unique == 2:
195
+ col.notes.append("Only two distinct values - probably a binary flag coded as numbers.")
196
+ elif 2 < n_unique <= 10 and pd.api.types.is_integer_dtype(s):
197
+ col.notes.append(
198
+ f"Integer with only {n_unique} distinct values - may be an ordinal or coded category."
199
+ )
200
+ skew = col.summary["skew"]
201
+ if skew is not None and abs(skew) >= 1:
202
+ direction = "right" if skew > 0 else "left"
203
+ col.notes.append(
204
+ f"Strongly {direction}-skewed (skew={skew}); the median and IQR describe the centre "
205
+ "and spread better than the mean and SD."
206
+ )
207
+ if n_outliers:
208
+ col.notes.append(f"{n_outliers} values outside 1.5xIQR (potential outliers).")
209
+ else:
210
+ # object / string / categorical dtype
211
+ if isinstance(s.dtype, pd.CategoricalDtype):
212
+ col.kind = "categorical"
213
+ elif n_unique == n_rows and n_rows > 20:
214
+ col.kind = "identifier" if _infer_meaning(name) and "identifier" in _infer_meaning(name) else "text"
215
+ elif n_unique <= max(20, int(0.05 * n_rows)) or n_unique / max(len(non_null), 1) <= 0.5:
216
+ col.kind = "categorical"
217
+ else:
218
+ col.kind = "text"
219
+
220
+ top = non_null.astype(str).value_counts().head(5)
221
+ col.summary = {"top_values": {str(k): int(v) for k, v in top.items()}}
222
+
223
+ if _looks_numeric_text(s) > 0.95 and n_unique > 10:
224
+ col.notes.append(
225
+ "Stored as text but looks numeric - convert with pd.to_numeric(..., errors='coerce') "
226
+ "after checking which values fail to parse."
227
+ )
228
+ elif _looks_datetime_text(s) > 0.95:
229
+ col.notes.append("Stored as text but looks like dates - consider pd.to_datetime().")
230
+
231
+ if col.kind == "categorical":
232
+ as_str = non_null.astype(str)
233
+ normalised = as_str.str.strip().str.lower()
234
+ if normalised.nunique() < as_str.nunique():
235
+ col.notes.append(
236
+ "Inconsistent labels: some categories differ only by case or whitespace "
237
+ "(e.g. 'Male' vs 'male ')."
238
+ )
239
+ if n_unique > 15:
240
+ col.notes.append(f"High cardinality ({n_unique} categories) - pie charts will be unreadable.")
241
+ counts = as_str.value_counts()
242
+ rare = int((counts < 5).sum())
243
+ if rare and n_unique > 2:
244
+ col.notes.append(f"{rare} categories have fewer than 5 rows (sparse for chi-square tests).")
245
+
246
+ if n_unique <= 1 and n_rows > 1:
247
+ col.notes.append("Constant column - carries no information.")
248
+ if col.pct_missing >= 50:
249
+ col.notes.append(f"{col.pct_missing}% missing - analyses using this column will lose most rows.")
250
+ elif n_missing:
251
+ col.notes.append(f"{n_missing} missing values ({col.pct_missing}%).")
252
+ return col
253
+
254
+
255
+ def _suggest_analyses(p: DatasetProfile) -> list[str]:
256
+ out: list[str] = []
257
+ num = p.numeric
258
+ cat = [c for c in p.categorical if p.columns[c].n_unique <= 30]
259
+ dt = p.columns_of_kind("datetime")
260
+
261
+ for c in num[:6]:
262
+ col = p.columns[c]
263
+ if col.skew is not None and abs(col.skew) >= 1:
264
+ out.append(f"`{c}`: histogram + boxplot; report median/IQR (skewed). Consider np.log1p for modelling.")
265
+ else:
266
+ out.append(f"`{c}`: histogram (`df['{c}'].plot.hist()`) and describe() for centre/spread.")
267
+ for corr in p.correlations[:4]:
268
+ out.append(
269
+ f"`{corr['a']}` vs `{corr['b']}`: scatter plot (r={corr['r']}); check linearity before Pearson, "
270
+ "or use Spearman if skewed."
271
+ )
272
+ for c in cat[:3]:
273
+ k = p.columns[c].n_unique
274
+ chart = "bar chart" if k > 5 else "bar chart (a pie only works for a few parts of one whole)"
275
+ out.append(f"`{c}`: `value_counts()` + {chart}.")
276
+ for n in num[:2]:
277
+ if k == 2:
278
+ out.append(f"`{n}` by `{c}`: grouped boxplot; Welch's t-test (or Mann-Whitney U if skewed).")
279
+ elif 2 < k <= 10:
280
+ out.append(f"`{n}` by `{c}`: grouped boxplot; one-way ANOVA (or Kruskal-Wallis if skewed).")
281
+ if len(cat) >= 2:
282
+ out.append(f"`{cat[0]}` x `{cat[1]}`: `pd.crosstab` + chi-square test of independence.")
283
+ for d in dt[:1]:
284
+ for n in num[:2]:
285
+ out.append(f"`{n}` over `{d}`: line plot after sorting by date / resampling.")
286
+ return out
287
+
288
+
289
+ def profile_dataframe(df: pd.DataFrame, name: str, source: str | None = None) -> DatasetProfile:
290
+ n_rows, n_cols = df.shape
291
+ columns = {str(c): _profile_column(df[c], n_rows) for c in df.columns}
292
+
293
+ correlations: list[dict[str, Any]] = []
294
+ num_cols = [c for c, p in columns.items() if p.kind == "numeric" and p.n_unique > 2]
295
+ if 2 <= len(num_cols) <= 60:
296
+ corr = df[num_cols].corr(numeric_only=True)
297
+ seen = set()
298
+ for a in num_cols:
299
+ for b in num_cols:
300
+ if a >= b or (a, b) in seen:
301
+ continue
302
+ seen.add((a, b))
303
+ r = corr.loc[a, b]
304
+ if pd.notna(r) and abs(r) >= 0.5:
305
+ correlations.append({"a": a, "b": b, "r": round(float(r), 3)})
306
+ correlations.sort(key=lambda d: -abs(d["r"]))
307
+
308
+ try:
309
+ n_dup = int(df.duplicated().sum())
310
+ except TypeError:
311
+ n_dup = 0
312
+ n_complete = int(df.dropna().shape[0])
313
+
314
+ profile = DatasetProfile(
315
+ name=name,
316
+ source=source,
317
+ n_rows=n_rows,
318
+ n_cols=n_cols,
319
+ fingerprint=fingerprint(df),
320
+ columns=columns,
321
+ correlations=correlations,
322
+ n_duplicate_rows=n_dup,
323
+ n_complete_rows=n_complete,
324
+ created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
325
+ )
326
+
327
+ issues: list[str] = []
328
+ if n_dup:
329
+ issues.append(f"{n_dup} fully duplicated rows.")
330
+ if n_complete < n_rows:
331
+ dropped = n_rows - n_complete
332
+ issues.append(
333
+ f"`df.dropna()` would drop {dropped} of {n_rows} rows ({100 * dropped / max(n_rows, 1):.1f}%)."
334
+ )
335
+ for c in columns.values():
336
+ for note in c.notes:
337
+ if any(w in note for w in ("missing", "Inconsistent", "Stored as text", "Constant", "outliers")):
338
+ issues.append(f"`{c.name}`: {note}")
339
+ for corr in correlations:
340
+ if abs(corr["r"]) >= 0.95:
341
+ issues.append(f"`{corr['a']}` and `{corr['b']}` are nearly collinear (r={corr['r']}).")
342
+ profile.issues = issues
343
+ profile.suggested_analyses = _suggest_analyses(profile)
344
+ return profile
learnstat/providers.py ADDED
@@ -0,0 +1,78 @@
1
+ """Presets for the AI companies learnstat can talk to.
2
+
3
+ Every preset is an OpenAI-compatible ``/chat/completions`` endpoint, so one small
4
+ client covers them all. ``custom`` lets the user type any other endpoint.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Provider:
14
+ id: str
15
+ name: str
16
+ base_url: str
17
+ needs_key: bool = True
18
+ key_url: str = "" # where the user creates an API key
19
+ models: tuple[str, ...] = () # suggestions, used only when the live model list can't be fetched
20
+ note: str = ""
21
+
22
+
23
+ PROVIDERS: tuple[Provider, ...] = (
24
+ Provider("openai", "OpenAI", "https://api.openai.com/v1",
25
+ key_url="https://platform.openai.com/api-keys",
26
+ models=("gpt-4o-mini", "gpt-4o")),
27
+ Provider("anthropic", "Anthropic (Claude)", "https://api.anthropic.com/v1",
28
+ key_url="https://console.anthropic.com/settings/keys",
29
+ models=("claude-sonnet-5-5", "claude-haiku-5-5", "claude-opus-5-5")),
30
+ Provider("gemini", "Google (Gemini)", "https://generativelanguage.googleapis.com/v1beta/openai",
31
+ key_url="https://aistudio.google.com/apikey",
32
+ models=("gemini-2.5-flash", "gemini-2.5-pro")),
33
+ Provider("xai", "xAI (Grok)", "https://api.x.ai/v1",
34
+ key_url="https://console.x.ai"),
35
+ Provider("mistral", "Mistral AI", "https://api.mistral.ai/v1",
36
+ key_url="https://console.mistral.ai/api-keys",
37
+ models=("mistral-small-latest", "mistral-large-latest")),
38
+ Provider("deepseek", "DeepSeek", "https://api.deepseek.com/v1",
39
+ key_url="https://platform.deepseek.com/api_keys",
40
+ models=("deepseek-chat", "deepseek-reasoner")),
41
+ Provider("groq", "Groq", "https://api.groq.com/openai/v1",
42
+ key_url="https://console.groq.com/keys",
43
+ models=("llama-3.3-70b-versatile",)),
44
+ Provider("together", "Together AI", "https://api.together.xyz/v1",
45
+ key_url="https://api.together.ai/settings/api-keys"),
46
+ Provider("openrouter", "OpenRouter (many models, one key)", "https://openrouter.ai/api/v1",
47
+ key_url="https://openrouter.ai/keys",
48
+ models=("openai/gpt-4o-mini",)),
49
+ Provider("ollama", "Ollama (local, no key)", "http://localhost:11434/v1", needs_key=False,
50
+ note="Start Ollama first and pull a model, e.g. `ollama pull llama3.2`."),
51
+ Provider("lmstudio", "LM Studio (local, no key)", "http://localhost:1234/v1", needs_key=False,
52
+ note="Start LM Studio's local server and load a model first."),
53
+ )
54
+
55
+ CUSTOM = Provider("custom", "Other (any OpenAI-compatible endpoint)", "", needs_key=True)
56
+
57
+ _BY_ID = {p.id: p for p in PROVIDERS}
58
+
59
+
60
+ def get(provider_id: str | None) -> Provider | None:
61
+ if provider_id == "custom":
62
+ return CUSTOM
63
+ return _BY_ID.get(provider_id or "")
64
+
65
+
66
+ def all_choices() -> tuple[Provider, ...]:
67
+ """The providers in menu order, with the custom option last."""
68
+ return PROVIDERS + (CUSTOM,)
69
+
70
+
71
+ # Models that are not chat models; hidden from the model menu (but still usable if typed).
72
+ _NON_CHAT = ("embed", "whisper", "tts", "dall-e", "moderation", "transcribe", "audio", "realtime",
73
+ "image", "imagen", "babbage", "davinci", "rerank", "guard", "veo", "aqa")
74
+
75
+
76
+ def is_chat_model(model_id: str) -> bool:
77
+ low = model_id.lower()
78
+ return not any(tag in low for tag in _NON_CHAT)