plotaviz 0.1.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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""The preprocessing pipeline — an ordered, replayable list of steps.
|
|
2
|
+
|
|
3
|
+
This module is deliberately not a set of functions that mutate a dataframe. Every transformation
|
|
4
|
+
is a :class:`Step` object that can be serialized, replayed, reordered, removed, and rendered as
|
|
5
|
+
source code. One design buys four features:
|
|
6
|
+
|
|
7
|
+
* **Undo/redo** — drop the last step and re-run from the original frame.
|
|
8
|
+
* **Session replay** — a ``.pviz`` file stores the step list, not the cleaned data.
|
|
9
|
+
* **Code generation** — each step knows how to write itself as pandas source.
|
|
10
|
+
* **Cheap type overrides** — changing an inferred type re-runs a short list, not a bespoke path.
|
|
11
|
+
|
|
12
|
+
Steps never mutate their input. Each returns a new frame plus a :class:`StepReport` describing
|
|
13
|
+
what changed, and those reports are what the cleaning report in the UI is built from.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, ClassVar, Literal, cast
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from .errors import PreprocessError
|
|
26
|
+
from .profiler import BOOLEAN, CATEGORICAL, DATETIME, NUMERIC, TEXT
|
|
27
|
+
|
|
28
|
+
#: Missing-value strategies accepted by :class:`FillMissing`.
|
|
29
|
+
FILL_STRATEGIES: tuple[str, ...] = ("drop", "mean", "median", "mode", "ffill", "bfill", "zero")
|
|
30
|
+
|
|
31
|
+
#: Outlier detection methods accepted by :class:`FlagOutliers`.
|
|
32
|
+
OUTLIER_METHODS: tuple[str, ...] = ("iqr", "zscore")
|
|
33
|
+
|
|
34
|
+
#: Column appended by :class:`FlagOutliers`. Flagging is non-destructive by default.
|
|
35
|
+
OUTLIER_FLAG_SUFFIX = "__outlier"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class StepReport:
|
|
40
|
+
"""What one step actually did.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
step: The step's ``kind``.
|
|
44
|
+
summary: One-line, user-facing description of the change.
|
|
45
|
+
rows_before: Row count entering the step.
|
|
46
|
+
rows_after: Row count leaving it.
|
|
47
|
+
details: Structured extras (per-column counts, chosen fill values).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
step: str
|
|
51
|
+
summary: str
|
|
52
|
+
rows_before: int = 0
|
|
53
|
+
rows_after: int = 0
|
|
54
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def rows_removed(self) -> int:
|
|
58
|
+
"""Rows dropped by this step (never negative)."""
|
|
59
|
+
return max(0, self.rows_before - self.rows_after)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Step:
|
|
63
|
+
"""Base class for a replayable preprocessing step.
|
|
64
|
+
|
|
65
|
+
Subclasses implement :meth:`apply` and :meth:`to_code`, declare a unique :attr:`kind`, and
|
|
66
|
+
are registered automatically for deserialization.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
#: Unique identifier used in session files and the registry.
|
|
70
|
+
kind: ClassVar[str] = "step"
|
|
71
|
+
|
|
72
|
+
#: Populated by ``__init_subclass__``; maps ``kind`` to the class.
|
|
73
|
+
registry: ClassVar[dict[str, type[Step]]] = {}
|
|
74
|
+
|
|
75
|
+
#: Human-readable label for the UI's step list.
|
|
76
|
+
label: str = "Step"
|
|
77
|
+
|
|
78
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
79
|
+
super().__init_subclass__(**kwargs)
|
|
80
|
+
if cls.kind != "step":
|
|
81
|
+
Step.registry[cls.kind] = cls
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ contract
|
|
84
|
+
|
|
85
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
86
|
+
"""Return a new frame plus a report. Must not mutate ``df``."""
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
def to_code(self) -> list[str]:
|
|
90
|
+
"""Return pandas source lines reproducing this step on a variable named ``df``."""
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
"""Serialize for a session file."""
|
|
95
|
+
payload = {k: v for k, v in vars(self).items() if not k.startswith("_")}
|
|
96
|
+
return {"kind": self.kind, **payload}
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def from_dict(data: dict[str, Any]) -> Step:
|
|
100
|
+
"""Rebuild a step from its serialized form.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
PreprocessError: If ``kind`` is missing or unknown (e.g. a newer session file).
|
|
104
|
+
"""
|
|
105
|
+
data = dict(data)
|
|
106
|
+
kind = data.pop("kind", None)
|
|
107
|
+
if kind is None:
|
|
108
|
+
raise PreprocessError("A preprocessing step is missing its 'kind' field.")
|
|
109
|
+
cls = Step.registry.get(str(kind))
|
|
110
|
+
if cls is None:
|
|
111
|
+
raise PreprocessError(
|
|
112
|
+
f"Unknown preprocessing step {kind!r}.",
|
|
113
|
+
hint="This session may have been saved by a newer version of PlotaViz.",
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
return cls(**data)
|
|
117
|
+
except TypeError as exc:
|
|
118
|
+
raise PreprocessError(
|
|
119
|
+
f"Preprocessing step {kind!r} has parameters this version does not understand.",
|
|
120
|
+
hint=str(exc),
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items() if not k.startswith("_"))
|
|
125
|
+
return f"{type(self).__name__}({args})"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------- steps
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class NormalizeColumnNames(Step):
|
|
132
|
+
"""Strip whitespace and convert column names to ``snake_case``.
|
|
133
|
+
|
|
134
|
+
Runs first in the default pipeline so every later step, filter, and generated script can
|
|
135
|
+
refer to columns by a predictable name.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
kind = "normalize_columns"
|
|
139
|
+
label = "Normalize column names"
|
|
140
|
+
|
|
141
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
142
|
+
self.enabled = enabled
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def normalize(name: str) -> str:
|
|
146
|
+
"""Convert one column name to snake_case, preserving digits and collapsing separators."""
|
|
147
|
+
text = str(name).strip()
|
|
148
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
|
|
149
|
+
text = re.sub(r"[^0-9a-zA-Z]+", "_", text)
|
|
150
|
+
text = re.sub(r"_+", "_", text).strip("_").lower()
|
|
151
|
+
return text or "column"
|
|
152
|
+
|
|
153
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
154
|
+
if not self.enabled:
|
|
155
|
+
return df, StepReport(self.kind, "Column names left as they are.", len(df), len(df))
|
|
156
|
+
|
|
157
|
+
mapping: dict[str, str] = {}
|
|
158
|
+
used: set[str] = set()
|
|
159
|
+
for col in df.columns:
|
|
160
|
+
new = self.normalize(col)
|
|
161
|
+
if new in used: # de-duplicate collisions deterministically
|
|
162
|
+
suffix = 2
|
|
163
|
+
while f"{new}_{suffix}" in used:
|
|
164
|
+
suffix += 1
|
|
165
|
+
new = f"{new}_{suffix}"
|
|
166
|
+
used.add(new)
|
|
167
|
+
mapping[str(col)] = new
|
|
168
|
+
|
|
169
|
+
renamed = {k: v for k, v in mapping.items() if k != v}
|
|
170
|
+
out = df.rename(columns=mapping)
|
|
171
|
+
summary = (
|
|
172
|
+
f"Renamed {len(renamed)} column(s) to snake_case."
|
|
173
|
+
if renamed
|
|
174
|
+
else "Column names were already clean."
|
|
175
|
+
)
|
|
176
|
+
return out, StepReport(self.kind, summary, len(df), len(out), {"renamed": renamed})
|
|
177
|
+
|
|
178
|
+
def to_code(self) -> list[str]:
|
|
179
|
+
if not self.enabled:
|
|
180
|
+
return []
|
|
181
|
+
return [
|
|
182
|
+
"# Normalize column names to snake_case",
|
|
183
|
+
"import re",
|
|
184
|
+
"def _snake(name):",
|
|
185
|
+
" text = re.sub(r'([a-z0-9])([A-Z])', r'\\1_\\2', str(name).strip())",
|
|
186
|
+
" text = re.sub(r'[^0-9a-zA-Z]+', '_', text)",
|
|
187
|
+
" return re.sub(r'_+', '_', text).strip('_').lower() or 'column'",
|
|
188
|
+
"df = df.rename(columns={c: _snake(c) for c in df.columns})",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CoerceTypes(Step):
|
|
193
|
+
"""Apply column type decisions — inferred roles and any user overrides.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
types: ``{column: role}`` where role is one of the profiler's roles. Columns not listed
|
|
197
|
+
are left alone.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
kind = "coerce_types"
|
|
201
|
+
label = "Apply column types"
|
|
202
|
+
|
|
203
|
+
def __init__(self, types: dict[str, str] | None = None) -> None:
|
|
204
|
+
self.types = dict(types or {})
|
|
205
|
+
|
|
206
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
207
|
+
out = df.copy()
|
|
208
|
+
applied: dict[str, str] = {}
|
|
209
|
+
failed: dict[str, str] = {}
|
|
210
|
+
|
|
211
|
+
for col, role in self.types.items():
|
|
212
|
+
if col not in out.columns:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
if role == DATETIME:
|
|
216
|
+
out[col] = pd.to_datetime(out[col], errors="coerce", format="mixed")
|
|
217
|
+
elif role == NUMERIC:
|
|
218
|
+
out[col] = pd.to_numeric(out[col], errors="coerce")
|
|
219
|
+
elif role == BOOLEAN:
|
|
220
|
+
out[col] = _to_boolean(out[col])
|
|
221
|
+
elif role in (CATEGORICAL, TEXT):
|
|
222
|
+
out[col] = out[col].astype("object")
|
|
223
|
+
else:
|
|
224
|
+
continue
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
failed[col] = str(exc)
|
|
227
|
+
continue
|
|
228
|
+
applied[col] = role
|
|
229
|
+
|
|
230
|
+
parts = []
|
|
231
|
+
if applied:
|
|
232
|
+
parts.append(f"Applied types to {len(applied)} column(s).")
|
|
233
|
+
if failed:
|
|
234
|
+
parts.append(f"{len(failed)} column(s) could not be converted and were left as-is.")
|
|
235
|
+
summary = " ".join(parts) or "No type changes were needed."
|
|
236
|
+
return out, StepReport(
|
|
237
|
+
self.kind, summary, len(df), len(out), {"applied": applied, "failed": failed}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def to_code(self) -> list[str]:
|
|
241
|
+
lines: list[str] = []
|
|
242
|
+
for col, role in self.types.items():
|
|
243
|
+
if role == DATETIME:
|
|
244
|
+
lines.append(f"df[{col!r}] = pd.to_datetime(df[{col!r}], errors='coerce')")
|
|
245
|
+
elif role == NUMERIC:
|
|
246
|
+
lines.append(f"df[{col!r}] = pd.to_numeric(df[{col!r}], errors='coerce')")
|
|
247
|
+
elif role == BOOLEAN:
|
|
248
|
+
lines.append(f"df[{col!r}] = df[{col!r}].astype('boolean')")
|
|
249
|
+
return ["# Column types", *lines] if lines else []
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class FillMissing(Step):
|
|
253
|
+
"""Handle missing values with an explicit, reported strategy.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
strategy: One of :data:`FILL_STRATEGIES`. ``"drop"`` removes rows with any null in the
|
|
257
|
+
targeted columns; the rest impute.
|
|
258
|
+
columns: Columns to act on. ``None`` means every column the strategy makes sense for.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
kind = "fill_missing"
|
|
262
|
+
label = "Handle missing values"
|
|
263
|
+
|
|
264
|
+
def __init__(self, strategy: str = "median", columns: list[str] | None = None) -> None:
|
|
265
|
+
if strategy not in FILL_STRATEGIES:
|
|
266
|
+
raise PreprocessError(
|
|
267
|
+
f"Unknown missing-value strategy {strategy!r}.",
|
|
268
|
+
hint=f"Choose one of: {', '.join(FILL_STRATEGIES)}.",
|
|
269
|
+
)
|
|
270
|
+
self.strategy = strategy
|
|
271
|
+
self.columns = list(columns) if columns else None
|
|
272
|
+
|
|
273
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
274
|
+
out = df.copy()
|
|
275
|
+
targets = [c for c in (self.columns or out.columns) if c in out.columns]
|
|
276
|
+
before_missing = {c: int(out[c].isna().sum()) for c in targets}
|
|
277
|
+
touched = {c: n for c, n in before_missing.items() if n > 0}
|
|
278
|
+
|
|
279
|
+
if not touched:
|
|
280
|
+
return out, StepReport(
|
|
281
|
+
self.kind, "No missing values to handle.", len(df), len(out), {"filled": {}}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
fills: dict[str, Any] = {}
|
|
285
|
+
if self.strategy == "drop":
|
|
286
|
+
out = out.dropna(subset=list(touched))
|
|
287
|
+
else:
|
|
288
|
+
for col in touched:
|
|
289
|
+
series = out[col]
|
|
290
|
+
value: Any = None
|
|
291
|
+
if self.strategy == "mean" and pd.api.types.is_numeric_dtype(series):
|
|
292
|
+
value = series.mean()
|
|
293
|
+
elif self.strategy == "median" and pd.api.types.is_numeric_dtype(series):
|
|
294
|
+
value = series.median()
|
|
295
|
+
elif self.strategy == "zero" and pd.api.types.is_numeric_dtype(series):
|
|
296
|
+
value = 0
|
|
297
|
+
elif self.strategy == "mode":
|
|
298
|
+
modes = series.mode(dropna=True)
|
|
299
|
+
value = modes.iloc[0] if not modes.empty else None
|
|
300
|
+
elif self.strategy == "ffill":
|
|
301
|
+
out[col] = series.ffill()
|
|
302
|
+
fills[col] = "forward-filled"
|
|
303
|
+
continue
|
|
304
|
+
elif self.strategy == "bfill":
|
|
305
|
+
out[col] = series.bfill()
|
|
306
|
+
fills[col] = "back-filled"
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
|
310
|
+
# A non-numeric column under a numeric strategy: leave it, and say so.
|
|
311
|
+
continue
|
|
312
|
+
out[col] = series.fillna(value)
|
|
313
|
+
fills[col] = value
|
|
314
|
+
|
|
315
|
+
summary = (
|
|
316
|
+
f"Dropped rows with missing values in {len(touched)} column(s)."
|
|
317
|
+
if self.strategy == "drop"
|
|
318
|
+
else f"Filled missing values in {len(fills)} column(s) using the "
|
|
319
|
+
f"{self.strategy} strategy."
|
|
320
|
+
)
|
|
321
|
+
return out, StepReport(
|
|
322
|
+
self.kind,
|
|
323
|
+
summary,
|
|
324
|
+
len(df),
|
|
325
|
+
len(out),
|
|
326
|
+
{"strategy": self.strategy, "missing_before": touched, "filled": _stringify(fills)},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def to_code(self) -> list[str]:
|
|
330
|
+
cols = f"{self.columns!r}" if self.columns else "None"
|
|
331
|
+
if self.strategy == "drop":
|
|
332
|
+
subset = f"subset={cols}" if self.columns else ""
|
|
333
|
+
return ["# Missing values: drop", f"df = df.dropna({subset})"]
|
|
334
|
+
if self.strategy in {"ffill", "bfill"}:
|
|
335
|
+
method = "ffill" if self.strategy == "ffill" else "bfill"
|
|
336
|
+
return [f"# Missing values: {method}", f"df = df.{method}()"]
|
|
337
|
+
if self.strategy == "zero":
|
|
338
|
+
return [
|
|
339
|
+
"# Missing values: fill numeric columns with 0",
|
|
340
|
+
"_num = df.select_dtypes('number').columns",
|
|
341
|
+
"df[_num] = df[_num].fillna(0)",
|
|
342
|
+
]
|
|
343
|
+
if self.strategy == "mode":
|
|
344
|
+
return [
|
|
345
|
+
"# Missing values: fill with the most frequent value",
|
|
346
|
+
"for _c in df.columns:",
|
|
347
|
+
" _modes = df[_c].mode(dropna=True)",
|
|
348
|
+
" if not _modes.empty:",
|
|
349
|
+
" df[_c] = df[_c].fillna(_modes.iloc[0])",
|
|
350
|
+
]
|
|
351
|
+
return [
|
|
352
|
+
f"# Missing values: fill numeric columns with the column {self.strategy}",
|
|
353
|
+
"_num = df.select_dtypes('number').columns",
|
|
354
|
+
f"df[_num] = df[_num].fillna(df[_num].{self.strategy}())",
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class FlagOutliers(Step):
|
|
359
|
+
"""Flag outliers without removing them.
|
|
360
|
+
|
|
361
|
+
Non-destructive by default: a boolean ``<column>__outlier`` companion column is added and the
|
|
362
|
+
count is reported. Removing data silently is how people end up with charts that lie.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
method: ``"iqr"`` (Tukey fences) or ``"zscore"``.
|
|
366
|
+
threshold: IQR multiplier, or the absolute z-score cutoff.
|
|
367
|
+
columns: Numeric columns to check. ``None`` checks all numeric columns.
|
|
368
|
+
drop: When True, drop flagged rows instead of marking them.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
kind = "flag_outliers"
|
|
372
|
+
label = "Flag outliers"
|
|
373
|
+
|
|
374
|
+
def __init__(
|
|
375
|
+
self,
|
|
376
|
+
method: str = "iqr",
|
|
377
|
+
threshold: float = 1.5,
|
|
378
|
+
columns: list[str] | None = None,
|
|
379
|
+
drop: bool = False,
|
|
380
|
+
) -> None:
|
|
381
|
+
if method not in OUTLIER_METHODS:
|
|
382
|
+
raise PreprocessError(
|
|
383
|
+
f"Unknown outlier method {method!r}.",
|
|
384
|
+
hint=f"Choose one of: {', '.join(OUTLIER_METHODS)}.",
|
|
385
|
+
)
|
|
386
|
+
self.method = method
|
|
387
|
+
self.threshold = float(threshold)
|
|
388
|
+
self.columns = list(columns) if columns else None
|
|
389
|
+
self.drop = bool(drop)
|
|
390
|
+
|
|
391
|
+
def _mask(self, series: pd.Series) -> pd.Series:
|
|
392
|
+
"""Boolean mask of outliers in one numeric series.
|
|
393
|
+
|
|
394
|
+
Both methods have a degenerate case: a column where most values are identical has an IQR
|
|
395
|
+
(and a standard deviation) of zero, and the naive fence then flags nothing — including a
|
|
396
|
+
value a thousand times larger than the rest, which is exactly the case a user cares about.
|
|
397
|
+
When that happens both fall back to the **modified z-score** built on the median absolute
|
|
398
|
+
deviation, with the conventional 3.5 cutoff.
|
|
399
|
+
"""
|
|
400
|
+
values = pd.to_numeric(series, errors="coerce")
|
|
401
|
+
|
|
402
|
+
if self.method == "iqr":
|
|
403
|
+
q1, q3 = values.quantile(0.25), values.quantile(0.75)
|
|
404
|
+
iqr = q3 - q1
|
|
405
|
+
if pd.isna(iqr):
|
|
406
|
+
return pd.Series(False, index=series.index)
|
|
407
|
+
if iqr != 0:
|
|
408
|
+
low, high = q1 - self.threshold * iqr, q3 + self.threshold * iqr
|
|
409
|
+
return ((values < low) | (values > high)).fillna(False)
|
|
410
|
+
return self._mad_mask(values)
|
|
411
|
+
|
|
412
|
+
std = values.std()
|
|
413
|
+
if pd.isna(std):
|
|
414
|
+
return pd.Series(False, index=series.index)
|
|
415
|
+
if std != 0:
|
|
416
|
+
return (((values - values.mean()).abs() / std) > self.threshold).fillna(False)
|
|
417
|
+
return self._mad_mask(values)
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _mad_mask(values: pd.Series) -> pd.Series:
|
|
421
|
+
"""Modified z-score fallback for distributions with no usable spread."""
|
|
422
|
+
median = values.median()
|
|
423
|
+
deviation = (values - median).abs()
|
|
424
|
+
mad = deviation.median()
|
|
425
|
+
if pd.isna(mad):
|
|
426
|
+
return pd.Series(False, index=values.index)
|
|
427
|
+
if mad == 0:
|
|
428
|
+
# Every value but a handful is identical; anything that differs is the anomaly.
|
|
429
|
+
return (values != median).fillna(False)
|
|
430
|
+
return ((0.6745 * deviation / mad) > 3.5).fillna(False)
|
|
431
|
+
|
|
432
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
433
|
+
out = df.copy()
|
|
434
|
+
targets = self.columns or list(out.select_dtypes(include="number").columns)
|
|
435
|
+
targets = [c for c in targets if c in out.columns]
|
|
436
|
+
|
|
437
|
+
counts: dict[str, int] = {}
|
|
438
|
+
any_mask = pd.Series(False, index=out.index)
|
|
439
|
+
for col in targets:
|
|
440
|
+
mask = self._mask(out[col]).fillna(False)
|
|
441
|
+
n = int(mask.sum())
|
|
442
|
+
if n == 0:
|
|
443
|
+
continue
|
|
444
|
+
counts[col] = n
|
|
445
|
+
any_mask |= mask
|
|
446
|
+
if not self.drop:
|
|
447
|
+
out[f"{col}{OUTLIER_FLAG_SUFFIX}"] = mask
|
|
448
|
+
|
|
449
|
+
if self.drop and counts:
|
|
450
|
+
out = out.loc[~any_mask]
|
|
451
|
+
|
|
452
|
+
total = int(any_mask.sum())
|
|
453
|
+
if not counts:
|
|
454
|
+
summary = "No outliers detected."
|
|
455
|
+
elif self.drop:
|
|
456
|
+
summary = f"Removed {total:,} outlier row(s) across {len(counts)} column(s)."
|
|
457
|
+
else:
|
|
458
|
+
summary = (
|
|
459
|
+
f"Flagged {total:,} outlier row(s) across {len(counts)} column(s). "
|
|
460
|
+
"The rows are kept; flag columns were added."
|
|
461
|
+
)
|
|
462
|
+
return out, StepReport(
|
|
463
|
+
self.kind,
|
|
464
|
+
summary,
|
|
465
|
+
len(df),
|
|
466
|
+
len(out),
|
|
467
|
+
{"method": self.method, "threshold": self.threshold, "per_column": counts},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def to_code(self) -> list[str]:
|
|
471
|
+
cols = repr(self.columns) if self.columns else "list(df.select_dtypes('number').columns)"
|
|
472
|
+
# The modified z-score fallback is emitted too, so the script flags the same rows the app
|
|
473
|
+
# did on columns whose spread is zero.
|
|
474
|
+
helper = [
|
|
475
|
+
"def _mad_outliers(s):",
|
|
476
|
+
' """Modified z-score, used when a column has no usable spread."""',
|
|
477
|
+
" _med = s.median()",
|
|
478
|
+
" _dev = (s - _med).abs()",
|
|
479
|
+
" _mad = _dev.median()",
|
|
480
|
+
" if _mad == 0:",
|
|
481
|
+
" return (s != _med).fillna(False)",
|
|
482
|
+
" return ((0.6745 * _dev / _mad) > 3.5).fillna(False)",
|
|
483
|
+
"",
|
|
484
|
+
]
|
|
485
|
+
if self.method == "iqr":
|
|
486
|
+
body = [
|
|
487
|
+
f"for _c in {cols}:",
|
|
488
|
+
" _q1, _q3 = df[_c].quantile(0.25), df[_c].quantile(0.75)",
|
|
489
|
+
" _iqr = _q3 - _q1",
|
|
490
|
+
" if _iqr:",
|
|
491
|
+
f" _mask = ((df[_c] < _q1 - {self.threshold} * _iqr) | "
|
|
492
|
+
f"(df[_c] > _q3 + {self.threshold} * _iqr)).fillna(False)",
|
|
493
|
+
" else:",
|
|
494
|
+
" _mask = _mad_outliers(df[_c])",
|
|
495
|
+
]
|
|
496
|
+
else:
|
|
497
|
+
body = [
|
|
498
|
+
f"for _c in {cols}:",
|
|
499
|
+
" _std = df[_c].std()",
|
|
500
|
+
" if _std:",
|
|
501
|
+
f" _mask = (((df[_c] - df[_c].mean()).abs() / _std) > "
|
|
502
|
+
f"{self.threshold}).fillna(False)",
|
|
503
|
+
" else:",
|
|
504
|
+
" _mask = _mad_outliers(df[_c])",
|
|
505
|
+
]
|
|
506
|
+
# Only touch the frame when something was actually flagged — matching the app, which
|
|
507
|
+
# does not add an all-False flag column for a column with no outliers.
|
|
508
|
+
tail = (
|
|
509
|
+
[" if _mask.any():", " df = df.loc[~_mask]"]
|
|
510
|
+
if self.drop
|
|
511
|
+
else [" if _mask.any():", f" df[_c + {OUTLIER_FLAG_SUFFIX!r}] = _mask"]
|
|
512
|
+
)
|
|
513
|
+
return [f"# Outliers ({self.method})", *helper, *body, *tail]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class Deduplicate(Step):
|
|
517
|
+
"""Remove duplicate rows and report how many went.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
subset: Columns that define a duplicate. ``None`` means the whole row.
|
|
521
|
+
keep: Which duplicate to keep — ``"first"``, ``"last"``, or ``False`` to drop all.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
kind = "deduplicate"
|
|
525
|
+
label = "Remove duplicate rows"
|
|
526
|
+
|
|
527
|
+
def __init__(self, subset: list[str] | None = None, keep: str = "first") -> None:
|
|
528
|
+
self.subset = list(subset) if subset else None
|
|
529
|
+
self.keep = keep
|
|
530
|
+
|
|
531
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
532
|
+
subset = [c for c in self.subset if c in df.columns] if self.subset else None
|
|
533
|
+
keep = cast(Literal["first", "last", False], self.keep)
|
|
534
|
+
out = df.drop_duplicates(subset=subset, keep=keep)
|
|
535
|
+
removed = len(df) - len(out)
|
|
536
|
+
summary = (
|
|
537
|
+
f"Removed {removed:,} duplicate row(s)." if removed else "No duplicate rows found."
|
|
538
|
+
)
|
|
539
|
+
return out, StepReport(self.kind, summary, len(df), len(out), {"removed": removed})
|
|
540
|
+
|
|
541
|
+
def to_code(self) -> list[str]:
|
|
542
|
+
subset = f"subset={self.subset!r}, " if self.subset else ""
|
|
543
|
+
return ["# Duplicates", f"df = df.drop_duplicates({subset}keep={self.keep!r})"]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class QueryFilter(Step):
|
|
547
|
+
"""Filter rows with a pandas ``query`` expression — the free-text filter bar.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
expression: A pandas query string, e.g. ``"revenue > 1000 and region == 'EMEA'"``.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
kind = "query_filter"
|
|
554
|
+
label = "Query filter"
|
|
555
|
+
|
|
556
|
+
def __init__(self, expression: str) -> None:
|
|
557
|
+
self.expression = str(expression)
|
|
558
|
+
|
|
559
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
560
|
+
expr = self.expression.strip()
|
|
561
|
+
if not expr:
|
|
562
|
+
return df, StepReport(self.kind, "Empty filter — nothing applied.", len(df), len(df))
|
|
563
|
+
try:
|
|
564
|
+
out = df.query(expr)
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
raise PreprocessError(
|
|
567
|
+
f"The filter {expr!r} could not be applied.",
|
|
568
|
+
hint=(
|
|
569
|
+
"Use pandas query syntax, e.g. revenue > 1000 and region == 'EMEA'. "
|
|
570
|
+
f"Underlying error: {exc}"
|
|
571
|
+
),
|
|
572
|
+
) from exc
|
|
573
|
+
return out, StepReport(
|
|
574
|
+
self.kind,
|
|
575
|
+
f"Filter {expr!r} kept {len(out):,} of {len(df):,} rows.",
|
|
576
|
+
len(df),
|
|
577
|
+
len(out),
|
|
578
|
+
{"expression": expr},
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def to_code(self) -> list[str]:
|
|
582
|
+
return ["# Filter", f"df = df.query({self.expression!r})"]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class ColumnFilter(Step):
|
|
586
|
+
"""Filter one column with a structured predicate — the per-column filter widgets.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
column: Column to filter on.
|
|
590
|
+
op: ``"between"`` (numeric range or date range) or ``"in"`` (multi-select).
|
|
591
|
+
value: ``[low, high]`` for ``between``, or a list of allowed values for ``in``.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
kind = "column_filter"
|
|
595
|
+
label = "Column filter"
|
|
596
|
+
|
|
597
|
+
def __init__(self, column: str, op: str, value: Any) -> None:
|
|
598
|
+
if op not in {"between", "in"}:
|
|
599
|
+
raise PreprocessError(f"Unknown filter operation {op!r}.")
|
|
600
|
+
self.column = str(column)
|
|
601
|
+
self.op = op
|
|
602
|
+
self.value = value
|
|
603
|
+
|
|
604
|
+
def apply(self, df: pd.DataFrame) -> tuple[pd.DataFrame, StepReport]:
|
|
605
|
+
if self.column not in df.columns:
|
|
606
|
+
return df, StepReport(
|
|
607
|
+
self.kind,
|
|
608
|
+
f"Column {self.column!r} is not present; filter skipped.",
|
|
609
|
+
len(df),
|
|
610
|
+
len(df),
|
|
611
|
+
)
|
|
612
|
+
series = df[self.column]
|
|
613
|
+
if self.op == "between":
|
|
614
|
+
low, high = self.value
|
|
615
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
616
|
+
low, high = pd.to_datetime(low), pd.to_datetime(high)
|
|
617
|
+
mask = series.between(low, high)
|
|
618
|
+
described = f"{self.column} between {low} and {high}"
|
|
619
|
+
else:
|
|
620
|
+
allowed = list(self.value)
|
|
621
|
+
mask = series.isin(allowed)
|
|
622
|
+
shown = ", ".join(map(str, allowed[:4]))
|
|
623
|
+
described = f"{self.column} in [{shown}{' …' if len(allowed) > 4 else ''}]"
|
|
624
|
+
|
|
625
|
+
out = df.loc[mask.fillna(False)]
|
|
626
|
+
return out, StepReport(
|
|
627
|
+
self.kind,
|
|
628
|
+
f"Filter {described} kept {len(out):,} of {len(df):,} rows.",
|
|
629
|
+
len(df),
|
|
630
|
+
len(out),
|
|
631
|
+
{"column": self.column, "op": self.op, "value": _stringify(self.value)},
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def to_code(self) -> list[str]:
|
|
635
|
+
if self.op == "between":
|
|
636
|
+
low, high = self.value
|
|
637
|
+
return [
|
|
638
|
+
"# Column filter",
|
|
639
|
+
f"df = df[df[{self.column!r}].between({low!r}, {high!r})]",
|
|
640
|
+
]
|
|
641
|
+
return ["# Column filter", f"df = df[df[{self.column!r}].isin({list(self.value)!r})]"]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ---------------------------------------------------------------------------- pipeline
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@dataclass
|
|
648
|
+
class PipelineResult:
|
|
649
|
+
"""The output of running a pipeline.
|
|
650
|
+
|
|
651
|
+
Attributes:
|
|
652
|
+
df: The cleaned frame.
|
|
653
|
+
reports: One :class:`StepReport` per step, in execution order.
|
|
654
|
+
rows_before: Rows in the input frame.
|
|
655
|
+
rows_after: Rows in :attr:`df`.
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
df: pd.DataFrame
|
|
659
|
+
reports: list[StepReport]
|
|
660
|
+
rows_before: int
|
|
661
|
+
rows_after: int
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def rows_removed(self) -> int:
|
|
665
|
+
"""Total rows removed across the whole pipeline."""
|
|
666
|
+
return max(0, self.rows_before - self.rows_after)
|
|
667
|
+
|
|
668
|
+
def cleaning_report(self) -> str:
|
|
669
|
+
"""Render the reports as the plain-text cleaning summary shown after loading."""
|
|
670
|
+
lines = [
|
|
671
|
+
f"Rows: {self.rows_before:,} → {self.rows_after:,}"
|
|
672
|
+
+ (f" ({self.rows_removed:,} removed)" if self.rows_removed else ""),
|
|
673
|
+
"",
|
|
674
|
+
]
|
|
675
|
+
for i, report in enumerate(self.reports, start=1):
|
|
676
|
+
lines.append(f"{i}. {report.summary}")
|
|
677
|
+
return "\n".join(lines)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class Pipeline:
|
|
681
|
+
"""An ordered list of steps applied to a dataframe.
|
|
682
|
+
|
|
683
|
+
The pipeline holds no data — it is a recipe. Re-running it against the original frame is how
|
|
684
|
+
undo, type overrides, and filter changes all work.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
steps: Initial steps, applied in order.
|
|
688
|
+
"""
|
|
689
|
+
|
|
690
|
+
def __init__(self, steps: list[Step] | None = None) -> None:
|
|
691
|
+
self.steps: list[Step] = list(steps or [])
|
|
692
|
+
|
|
693
|
+
# ------------------------------------------------------------------ mutation
|
|
694
|
+
|
|
695
|
+
def add(self, step: Step) -> Pipeline:
|
|
696
|
+
"""Append a step and return self, so calls chain."""
|
|
697
|
+
self.steps.append(step)
|
|
698
|
+
return self
|
|
699
|
+
|
|
700
|
+
def remove(self, index: int) -> Step:
|
|
701
|
+
"""Remove and return the step at ``index``."""
|
|
702
|
+
return self.steps.pop(index)
|
|
703
|
+
|
|
704
|
+
def replace_filters(self, filters: list[Step]) -> Pipeline:
|
|
705
|
+
"""Swap every filter step for a new set, leaving cleaning steps untouched.
|
|
706
|
+
|
|
707
|
+
Filters change on every widget interaction; cleaning does not. Keeping them separable is
|
|
708
|
+
what makes live filtering cheap.
|
|
709
|
+
"""
|
|
710
|
+
self.steps = [s for s in self.steps if not isinstance(s, (QueryFilter, ColumnFilter))]
|
|
711
|
+
self.steps.extend(filters)
|
|
712
|
+
return self
|
|
713
|
+
|
|
714
|
+
@property
|
|
715
|
+
def filters(self) -> list[Step]:
|
|
716
|
+
"""Just the filter steps, in order."""
|
|
717
|
+
return [s for s in self.steps if isinstance(s, (QueryFilter, ColumnFilter))]
|
|
718
|
+
|
|
719
|
+
def __len__(self) -> int:
|
|
720
|
+
return len(self.steps)
|
|
721
|
+
|
|
722
|
+
def __iter__(self):
|
|
723
|
+
return iter(self.steps)
|
|
724
|
+
|
|
725
|
+
# ------------------------------------------------------------------ execution
|
|
726
|
+
|
|
727
|
+
def run(self, df: pd.DataFrame) -> PipelineResult:
|
|
728
|
+
"""Apply every step in order to a copy of ``df``.
|
|
729
|
+
|
|
730
|
+
Raises:
|
|
731
|
+
PreprocessError: If a step fails. The message names the step so the user knows which
|
|
732
|
+
one to fix.
|
|
733
|
+
"""
|
|
734
|
+
rows_before = len(df)
|
|
735
|
+
current = df
|
|
736
|
+
reports: list[StepReport] = []
|
|
737
|
+
for step in self.steps:
|
|
738
|
+
try:
|
|
739
|
+
current, report = step.apply(current)
|
|
740
|
+
except PreprocessError:
|
|
741
|
+
raise
|
|
742
|
+
except Exception as exc:
|
|
743
|
+
raise PreprocessError(f"The step {step.label!r} failed.", hint=str(exc)) from exc
|
|
744
|
+
reports.append(report)
|
|
745
|
+
return PipelineResult(current, reports, rows_before, len(current))
|
|
746
|
+
|
|
747
|
+
# ------------------------------------------------------------------ serialization
|
|
748
|
+
|
|
749
|
+
def to_list(self) -> list[dict[str, Any]]:
|
|
750
|
+
"""Serialize every step for a session file."""
|
|
751
|
+
return [step.to_dict() for step in self.steps]
|
|
752
|
+
|
|
753
|
+
@classmethod
|
|
754
|
+
def from_list(cls, data: list[dict[str, Any]]) -> Pipeline:
|
|
755
|
+
"""Rebuild a pipeline from serialized steps."""
|
|
756
|
+
return cls([Step.from_dict(item) for item in data])
|
|
757
|
+
|
|
758
|
+
def to_code(self) -> list[str]:
|
|
759
|
+
"""Render the whole pipeline as pandas source lines."""
|
|
760
|
+
lines: list[str] = []
|
|
761
|
+
for step in self.steps:
|
|
762
|
+
code = step.to_code()
|
|
763
|
+
if code:
|
|
764
|
+
lines.extend(code)
|
|
765
|
+
lines.append("")
|
|
766
|
+
return lines
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def default_pipeline(
|
|
770
|
+
types: dict[str, str] | None = None,
|
|
771
|
+
*,
|
|
772
|
+
missing_strategy: str = "median",
|
|
773
|
+
flag_outliers: bool = True,
|
|
774
|
+
deduplicate: bool = True,
|
|
775
|
+
) -> Pipeline:
|
|
776
|
+
"""Build the standard cleaning pipeline applied right after loading.
|
|
777
|
+
|
|
778
|
+
The order matters: names first so everything downstream can address columns predictably,
|
|
779
|
+
then types (which is what user overrides feed into), then row-level cleaning.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
types: Column role decisions, including user overrides.
|
|
783
|
+
missing_strategy: One of :data:`FILL_STRATEGIES`.
|
|
784
|
+
flag_outliers: Whether to add outlier flag columns.
|
|
785
|
+
deduplicate: Whether to drop duplicate rows.
|
|
786
|
+
"""
|
|
787
|
+
pipeline = Pipeline([NormalizeColumnNames()])
|
|
788
|
+
if types:
|
|
789
|
+
pipeline.add(CoerceTypes(types))
|
|
790
|
+
if deduplicate:
|
|
791
|
+
pipeline.add(Deduplicate())
|
|
792
|
+
pipeline.add(FillMissing(strategy=missing_strategy))
|
|
793
|
+
if flag_outliers:
|
|
794
|
+
pipeline.add(FlagOutliers())
|
|
795
|
+
return pipeline
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# ---------------------------------------------------------------------------- helpers
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _to_boolean(series: pd.Series) -> pd.Series:
|
|
802
|
+
"""Coerce common truthy/falsey tokens to a nullable boolean dtype."""
|
|
803
|
+
if pd.api.types.is_bool_dtype(series):
|
|
804
|
+
return series.astype("boolean")
|
|
805
|
+
mapping = {
|
|
806
|
+
"true": True,
|
|
807
|
+
"yes": True,
|
|
808
|
+
"y": True,
|
|
809
|
+
"t": True,
|
|
810
|
+
"1": True,
|
|
811
|
+
"on": True,
|
|
812
|
+
"false": False,
|
|
813
|
+
"no": False,
|
|
814
|
+
"n": False,
|
|
815
|
+
"f": False,
|
|
816
|
+
"0": False,
|
|
817
|
+
"off": False,
|
|
818
|
+
}
|
|
819
|
+
return series.map(lambda v: mapping.get(str(v).strip().lower())).astype("boolean")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _stringify(value: Any) -> Any:
|
|
823
|
+
"""Make report details JSON-safe."""
|
|
824
|
+
if isinstance(value, dict):
|
|
825
|
+
return {str(k): _stringify(v) for k, v in value.items()}
|
|
826
|
+
if isinstance(value, (list, tuple)):
|
|
827
|
+
return [_stringify(v) for v in value]
|
|
828
|
+
if isinstance(value, (np.integer,)):
|
|
829
|
+
return int(value)
|
|
830
|
+
if isinstance(value, (np.floating,)):
|
|
831
|
+
return float(value)
|
|
832
|
+
if isinstance(value, (pd.Timestamp,)):
|
|
833
|
+
return value.isoformat()
|
|
834
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
835
|
+
return value
|
|
836
|
+
return str(value)
|