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.
@@ -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)