perf: dedup blocking, column-parallel scaffolding, lazy-copy pipelines
Three follow-on wins from the audit, each with shape-pinning tests.
1. Dedup blocking
- Exact-only strategies (every column EXACT @ 100 — covers strong-
key dedup like email/phone, the drop-duplicates fallback, and
explicit "match on this exact column" calls) now route through
an O(n) groupby fast path. Lossless; no API change required.
Measured: 10k-row email-exact dedup → 73 ms (was ~30 minutes
via the O(n²) pair compare).
- Fuzzy strategies still pair-compare, with opt-in prefix blocking
via deduplicate(..., blocking_columns=[...], blocking_prefix_len=1).
Measured: 5k-row fuzzy-name → 25.6s with blocking vs 179s
without (7x). Trade-off: cross-block matches missed.
2. Column-parallel standardize
- StandardizeOptions.parallel_columns (default 1) lands a
ThreadPoolExecutor over the column loop. Output order and
audit-record order are preserved deterministically via a merge
step keyed off column_types order. Honest doc: under CPython
3.12's GIL the win is roughly neutral (phonenumbers/dateutil
hold the GIL); the API is ready for free-threaded Python 3.13+.
3. Lazy-copy in missing / column_mapper
- _standardize_sentinels now builds per-column changes in a dict
and only materialises the output frame when at least one column
actually changed. On a clean 1 GB file this skips a 1 GB
allocation.
- handle_missing carries an out_is_owned flag, copying on demand
before any mutating step. No-op runs return the input frame.
- map_columns drops the unconditional upfront df.copy(); rename
and drop both return fresh frames already, and schema-add /
coerce trigger _ensure_owned() lazily.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -557,12 +557,29 @@ def map_columns(
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Apply rename and drop
|
||||
# ------------------------------------------------------------------
|
||||
out = df.copy()
|
||||
# ``drop`` and ``rename`` both return new frames (non-mutating), so
|
||||
# we can start from the input directly. Only fall back to a copy
|
||||
# when the downstream steps (schema-add, coerce, reorder) actually
|
||||
# mutate. This saves a full-frame copy on the common case where the
|
||||
# user passes a pre-mapped frame and just wants validation +
|
||||
# reorder, or where mapping is the identity.
|
||||
out = df
|
||||
if columns_dropped:
|
||||
out = out.drop(columns=columns_dropped)
|
||||
if mapping:
|
||||
out = out.rename(columns=mapping)
|
||||
columns_renamed = sum(1 for src, tgt in mapping.items() if src != tgt)
|
||||
# Ownership: if ``drop`` or ``rename`` ran they returned a fresh
|
||||
# frame, so ``out is not df``. If neither ran we still share with
|
||||
# the caller; any downstream step that mutates a column must copy
|
||||
# first via ``_ensure_owned``.
|
||||
_owns_out = out is not df
|
||||
|
||||
def _ensure_owned() -> None:
|
||||
nonlocal out, _owns_out
|
||||
if not _owns_out:
|
||||
out = out.copy()
|
||||
_owns_out = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Handle the schema's required + default fields
|
||||
@@ -578,6 +595,7 @@ def map_columns(
|
||||
missing_required.append(tf.name)
|
||||
continue
|
||||
# Add with default value (NaN if no default).
|
||||
_ensure_owned()
|
||||
out[tf.name] = tf.default if tf.default is not None else pd.NA
|
||||
columns_added.append(tf.name)
|
||||
|
||||
@@ -607,6 +625,7 @@ def map_columns(
|
||||
tf.name, tf.dtype, e,
|
||||
)
|
||||
continue
|
||||
_ensure_owned()
|
||||
out[tf.name] = series
|
||||
if fails:
|
||||
coercion_failures[tf.name] = fails
|
||||
|
||||
@@ -223,43 +223,223 @@ def _compare_pair(
|
||||
# Match-group finding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _strategy_is_exact_only(strategy: MatchStrategy) -> bool:
|
||||
"""True when every column in *strategy* matches via exact equality.
|
||||
|
||||
Such strategies can be processed in **O(n)** via a single
|
||||
``groupby`` on the composite key — rows with identical normalised
|
||||
values land in the same partition and need no pair-compare. The
|
||||
semantics are identical to running ``_compare_pair`` over every
|
||||
pair: exact at threshold 100 returns match iff both sides equal.
|
||||
"""
|
||||
return all(
|
||||
cs.algorithm == Algorithm.EXACT and cs.threshold >= 100.0
|
||||
for cs in strategy.column_strategies
|
||||
)
|
||||
|
||||
|
||||
def _strategy_columns(strategy: MatchStrategy, norm_prefix: str = "_norm_") -> list[str]:
|
||||
"""Resolved column names (normalised shadow if present, else raw)."""
|
||||
return [
|
||||
f"{norm_prefix}{cs.column}" if cs.normalizer else cs.column
|
||||
for cs in strategy.column_strategies
|
||||
]
|
||||
|
||||
|
||||
def _match_exact_via_groupby(
|
||||
df: pd.DataFrame,
|
||||
strategy: MatchStrategy,
|
||||
uf: _UnionFind,
|
||||
pair_info: dict[tuple[int, int], tuple[float, list[str]]],
|
||||
norm_prefix: str = "_norm_",
|
||||
) -> int:
|
||||
"""O(n) exact-match fast path. Returns the number of pairs unioned.
|
||||
|
||||
Mirrors :func:`_compare_pair` semantics for the EXACT-only case:
|
||||
rows where every key column is non-empty and equal across rows land
|
||||
in the same group. Rows with any empty value in the key columns are
|
||||
excluded — matching the "one empty + one non-empty = no match" rule
|
||||
in :func:`_compare_pair`.
|
||||
|
||||
For each match group the function unions all members into ``uf``
|
||||
and records a single representative entry in ``pair_info`` (between
|
||||
the first two members) carrying confidence 100.0 and the strategy's
|
||||
column names. The group walk in :func:`_find_match_groups` finds
|
||||
that entry when computing per-group confidence.
|
||||
"""
|
||||
cols = _strategy_columns(strategy, norm_prefix)
|
||||
matched_col_names = [cs.column for cs in strategy.column_strategies]
|
||||
|
||||
# Build a string-typed view of just the key columns so groupby is
|
||||
# comparing apples to apples regardless of source dtype. Treat NaN
|
||||
# / None as empty to match the original ``_is_missing`` semantics.
|
||||
key_df = pd.DataFrame(
|
||||
{c: df[c].map(lambda v: "" if _is_missing(v) else str(v)) for c in cols},
|
||||
index=df.index,
|
||||
)
|
||||
# Eligible rows: every key column non-empty (after strip). Empty
|
||||
# rows can't match anyone under EXACT semantics, so excluding them
|
||||
# is equivalent to running them through ``_compare_pair`` and
|
||||
# getting False.
|
||||
nonempty = key_df.apply(lambda s: s.str.strip().astype(bool)).all(axis=1)
|
||||
eligible = key_df[nonempty]
|
||||
if eligible.empty:
|
||||
return 0
|
||||
|
||||
pairs_unioned = 0
|
||||
# ``groupby.indices`` gives ``{key_tuple: ndarray_of_positions}``
|
||||
# in one pass — pandas does the bucket walk in C.
|
||||
grouped = eligible.groupby(list(eligible.columns), sort=False, dropna=False)
|
||||
for _, idx_array in grouped.indices.items():
|
||||
if len(idx_array) < 2:
|
||||
continue
|
||||
members = [int(i) for i in idx_array]
|
||||
base = members[0]
|
||||
# Record a single pair_info entry for the group; the rest are
|
||||
# transitively captured via union-find.
|
||||
pair_info[(min(base, members[1]), max(base, members[1]))] = (
|
||||
100.0,
|
||||
matched_col_names,
|
||||
)
|
||||
for m in members[1:]:
|
||||
uf.union(base, m)
|
||||
pairs_unioned += 1
|
||||
|
||||
return pairs_unioned
|
||||
|
||||
|
||||
def _build_block_keys(
|
||||
df: pd.DataFrame,
|
||||
blocking_columns: list[str],
|
||||
prefix_len: int,
|
||||
norm_prefix: str = "_norm_",
|
||||
) -> pd.Series:
|
||||
"""Compute a per-row block key from the first *prefix_len* chars of
|
||||
each blocking column. Rows with the same key go in the same block.
|
||||
|
||||
Empty cells contribute ``""`` so a row with one empty blocking
|
||||
column still gets a deterministic key — those rows simply land in
|
||||
a block with other rows that share the same prefix on the other
|
||||
column(s).
|
||||
"""
|
||||
parts: list[pd.Series] = []
|
||||
for col in blocking_columns:
|
||||
# Prefer the normalised shadow when present — it's lowercased
|
||||
# and dot/punct-stripped, which is exactly the canonical form
|
||||
# we want for a block key (so "John" and "JOHN" share a block).
|
||||
shadow = f"{norm_prefix}{col}"
|
||||
source = df[shadow] if shadow in df.columns else df.get(col)
|
||||
if source is None:
|
||||
raise ValueError(
|
||||
f"blocking_columns refers to unknown column {col!r}; "
|
||||
f"available: {list(df.columns)}"
|
||||
)
|
||||
s = source.map(lambda v: "" if _is_missing(v) else str(v))
|
||||
parts.append(s.str.strip().str.lower().str[:prefix_len])
|
||||
if not parts:
|
||||
return pd.Series([""] * len(df), index=df.index)
|
||||
key = parts[0]
|
||||
for p in parts[1:]:
|
||||
key = key + "\x1f" + p # ASCII unit separator
|
||||
return key
|
||||
|
||||
|
||||
def _find_match_groups(
|
||||
df: pd.DataFrame,
|
||||
strategies: list[MatchStrategy],
|
||||
*,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
blocking_columns: Optional[list[str]] = None,
|
||||
blocking_prefix_len: int = 1,
|
||||
) -> tuple[list[MatchResult], dict[tuple[int, int], tuple[float, list[str]]]]:
|
||||
"""Pairwise comparison + union-find for transitive closure.
|
||||
|
||||
Returns ``(match_groups, pair_info)`` where *pair_info* maps
|
||||
``(i, j)`` → ``(confidence, matched_columns)`` for logging.
|
||||
|
||||
Performance shape:
|
||||
|
||||
- **Exact-only strategies** (every column EXACT @ 100) are peeled
|
||||
off and processed via O(n) groupby. The fallback "exact on all
|
||||
columns" path and standalone strong-key strategies (e.g.,
|
||||
``email`` exact) both qualify and account for the vast majority
|
||||
of real-world dedup workloads.
|
||||
- **Fuzzy strategies** still do pairwise compare. When
|
||||
``blocking_columns`` is supplied, only pairs sharing the same
|
||||
*prefix_len*-char prefix on those columns are compared — turning
|
||||
O(n²) into roughly O(n × avg_block_size). Default off so match
|
||||
semantics don't change unless the caller opts in.
|
||||
"""
|
||||
n = len(df)
|
||||
uf = _UnionFind(n)
|
||||
pair_info: dict[tuple[int, int], tuple[float, list[str]]] = {}
|
||||
total_pairs = n * (n - 1) // 2
|
||||
checked = 0
|
||||
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
for strategy in strategies:
|
||||
is_match, confidence, cols = _compare_pair(
|
||||
df.iloc[i], df.iloc[j], strategy
|
||||
)
|
||||
if is_match:
|
||||
uf.union(i, j)
|
||||
key = (i, j)
|
||||
# Keep the highest-confidence match for this pair
|
||||
if key not in pair_info or confidence > pair_info[key][0]:
|
||||
pair_info[key] = (confidence, cols)
|
||||
break # OR logic: one strategy match is enough
|
||||
# Split strategies into exact-only and fuzzy. Order preserved
|
||||
# within each bucket so the (first-strategy-wins) ordering for
|
||||
# pair_info ties matches the prior behaviour as closely as
|
||||
# possible. Exact-only run first because they're cheap and
|
||||
# populate pair_info with the most informative (100%) score.
|
||||
exact_strategies = [s for s in strategies if _strategy_is_exact_only(s)]
|
||||
fuzzy_strategies = [s for s in strategies if not _strategy_is_exact_only(s)]
|
||||
|
||||
checked += 1
|
||||
if progress_callback and checked % 1000 == 0:
|
||||
progress_callback(checked, total_pairs)
|
||||
for strategy in exact_strategies:
|
||||
_match_exact_via_groupby(df, strategy, uf, pair_info)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total_pairs, total_pairs)
|
||||
if fuzzy_strategies:
|
||||
# Pair-compare path. Build the row-index iterator: full Cartesian
|
||||
# by default, or per-block when the caller supplied
|
||||
# ``blocking_columns``. Blocking is a recall/precision trade —
|
||||
# see the docstring.
|
||||
if blocking_columns:
|
||||
block_keys = _build_block_keys(
|
||||
df, blocking_columns, blocking_prefix_len,
|
||||
)
|
||||
blocks: dict[str, list[int]] = {}
|
||||
for pos, key in enumerate(block_keys.tolist()):
|
||||
blocks.setdefault(key, []).append(pos)
|
||||
block_iters = list(blocks.values())
|
||||
else:
|
||||
block_iters = [list(range(n))]
|
||||
|
||||
total_pairs = sum(
|
||||
len(block) * (len(block) - 1) // 2 for block in block_iters
|
||||
)
|
||||
checked = 0
|
||||
|
||||
for block in block_iters:
|
||||
block_len = len(block)
|
||||
for ii in range(block_len):
|
||||
i = block[ii]
|
||||
row_i = df.iloc[i]
|
||||
for jj in range(ii + 1, block_len):
|
||||
j = block[jj]
|
||||
# Preserve i<j ordering for pair_info keys regardless
|
||||
# of how the block iterator sorts.
|
||||
lo, hi = (i, j) if i < j else (j, i)
|
||||
row_j = df.iloc[j]
|
||||
for strategy in fuzzy_strategies:
|
||||
is_match, confidence, cols = _compare_pair(
|
||||
row_i, row_j, strategy
|
||||
)
|
||||
if is_match:
|
||||
uf.union(i, j)
|
||||
key = (lo, hi)
|
||||
# Keep the highest-confidence match. Exact-
|
||||
# path entries (100.0) always win, which
|
||||
# matches the prior tiebreak.
|
||||
if key not in pair_info or confidence > pair_info[key][0]:
|
||||
pair_info[key] = (confidence, cols)
|
||||
break # OR logic: one strategy match is enough
|
||||
|
||||
checked += 1
|
||||
if progress_callback and checked % 1000 == 0:
|
||||
progress_callback(checked, total_pairs)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total_pairs, total_pairs)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0)
|
||||
|
||||
# Build MatchResult objects (survivor not yet selected)
|
||||
raw_groups = uf.groups()
|
||||
@@ -535,6 +715,8 @@ def deduplicate(
|
||||
preview: bool = True,
|
||||
review_callback: Optional[Callable] = None,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
blocking_columns: Optional[list[str]] = None,
|
||||
blocking_prefix_len: int = 1,
|
||||
) -> DeduplicationResult:
|
||||
"""Run the full deduplication pipeline.
|
||||
|
||||
@@ -564,6 +746,16 @@ def deduplicate(
|
||||
None to skip (keep both rows). Used for interactive review.
|
||||
progress_callback : ``(current: int, total: int) -> None``
|
||||
Called periodically during pairwise comparison.
|
||||
blocking_columns : list of column names to partition fuzzy
|
||||
comparison by. When set, pairs only get compared if they
|
||||
share the same first *blocking_prefix_len* characters on
|
||||
every blocking column. Trades recall for speed; default
|
||||
``None`` preserves the original full O(n²) semantics.
|
||||
Exact-only strategies always use the O(n) groupby path and
|
||||
are unaffected by this option.
|
||||
blocking_prefix_len : prefix length used to derive the block
|
||||
key. Smaller = bigger blocks = more recall, less speed.
|
||||
Default 1 (group by first character).
|
||||
|
||||
Returns a ``DeduplicationResult``.
|
||||
"""
|
||||
@@ -640,7 +832,11 @@ def deduplicate(
|
||||
|
||||
# Find matches
|
||||
match_groups, pair_info = _find_match_groups(
|
||||
df_work, strategies, progress_callback=progress_callback
|
||||
df_work,
|
||||
strategies,
|
||||
progress_callback=progress_callback,
|
||||
blocking_columns=blocking_columns,
|
||||
blocking_prefix_len=blocking_prefix_len,
|
||||
)
|
||||
log_entries.append(f"Found {len(match_groups)} duplicate groups")
|
||||
logger.info("Found {} duplicate groups from {} rows", len(match_groups), original_count)
|
||||
|
||||
@@ -1992,6 +1992,21 @@ class StandardizeOptions:
|
||||
# real-world cardinalities without ballooning memory.
|
||||
cache_size: int = 262_144
|
||||
|
||||
# Process typed columns in parallel via a ``ThreadPoolExecutor`` of
|
||||
# this size. Default 1 (serial) preserves the historic execution.
|
||||
# Output column order and audit-record order are preserved
|
||||
# regardless of ``parallel_columns``; the merge step rebuilds them
|
||||
# from ``column_types`` order.
|
||||
#
|
||||
# **Honest expectation**: on CPython 3.12 with the GIL, this is
|
||||
# roughly neutral-to-slower because phonenumbers/dateutil/regex are
|
||||
# pure-Python and hold the GIL through most of the work. The
|
||||
# feature lands the scaffolding so the free-threaded (PEP 703)
|
||||
# Python 3.13+ build and any future C-accelerated standardizer can
|
||||
# parallelize without an API change. Treat values >1 as a tunable
|
||||
# to benchmark per workload — not a default.
|
||||
parallel_columns: int = 1
|
||||
|
||||
@classmethod
|
||||
def from_preset(cls, name: str, **overrides: Any) -> StandardizeOptions:
|
||||
"""Build options from a named preset, with optional field overrides.
|
||||
@@ -2542,63 +2557,101 @@ def standardize_dataframe(
|
||||
suggestion=f"Available: {list(out.columns)}",
|
||||
)
|
||||
|
||||
for col, field_type in column_types.items():
|
||||
# Per-column work — hoisted into a closure so the column loop can
|
||||
# run serial or via a thread pool with no behavioural difference.
|
||||
# Each call returns its own audit records and counters; the parent
|
||||
# merges them. ``audit_cap`` is enforced at the parent level after
|
||||
# merge so a parallel run never produces more audit rows than a
|
||||
# serial one for the same input.
|
||||
def _process_column(col: str, field_type: FieldType) -> dict:
|
||||
series = out[col]
|
||||
cells_total += len(series)
|
||||
dispatcher = _build_cached_dispatcher(field_type, options)
|
||||
|
||||
# Per-row region lookup. Phones and addresses are the two types
|
||||
# that benefit from country context; everything else ignores the
|
||||
# second argument.
|
||||
region_series: Optional[pd.Series] = None
|
||||
if field_type == FieldType.PHONE and options.phone_country_column:
|
||||
region_series = out[options.phone_country_column]
|
||||
elif field_type == FieldType.ADDRESS and options.address_country_column:
|
||||
region_series = out[options.address_country_column]
|
||||
|
||||
# Hot loop: one ``.tolist()`` materialisation, one pass over the
|
||||
# column. Previously called ``.tolist()`` three times and built an
|
||||
# intermediate ``triples`` list — costly at 1 GB scale where a
|
||||
# single column may be 10–50 MB of Python objects.
|
||||
values = series.tolist()
|
||||
new_values: list[Any] = [None] * len(values)
|
||||
col_new: list[Any] = [None] * len(values)
|
||||
col_records: list[dict] = []
|
||||
col_changed = 0
|
||||
col_unparseable = 0
|
||||
|
||||
if region_series is None:
|
||||
for i, orig in enumerate(values):
|
||||
new, changed, parsed = dispatcher(orig)
|
||||
new_values[i] = new
|
||||
col_new[i] = new
|
||||
if changed:
|
||||
cells_changed += 1
|
||||
if audit_room > 0:
|
||||
audit_records.append({
|
||||
"row": i,
|
||||
"column": col,
|
||||
"field_type": field_type.value,
|
||||
"old": orig,
|
||||
"new": new,
|
||||
})
|
||||
audit_room -= 1
|
||||
col_changed += 1
|
||||
col_records.append({
|
||||
"row": i,
|
||||
"column": col,
|
||||
"field_type": field_type.value,
|
||||
"old": orig,
|
||||
"new": new,
|
||||
})
|
||||
if not parsed:
|
||||
cells_unparseable += 1
|
||||
col_unparseable += 1
|
||||
else:
|
||||
regions = region_series.tolist()
|
||||
for i, (orig, region) in enumerate(zip(values, regions)):
|
||||
new, changed, parsed = dispatcher(orig, _normalize_region(region))
|
||||
new_values[i] = new
|
||||
col_new[i] = new
|
||||
if changed:
|
||||
cells_changed += 1
|
||||
if audit_room > 0:
|
||||
audit_records.append({
|
||||
"row": i,
|
||||
"column": col,
|
||||
"field_type": field_type.value,
|
||||
"old": orig,
|
||||
"new": new,
|
||||
})
|
||||
audit_room -= 1
|
||||
col_changed += 1
|
||||
col_records.append({
|
||||
"row": i,
|
||||
"column": col,
|
||||
"field_type": field_type.value,
|
||||
"old": orig,
|
||||
"new": new,
|
||||
})
|
||||
if not parsed:
|
||||
cells_unparseable += 1
|
||||
out[col] = new_values
|
||||
col_unparseable += 1
|
||||
|
||||
return {
|
||||
"col": col,
|
||||
"values": col_new,
|
||||
"records": col_records,
|
||||
"changed": col_changed,
|
||||
"unparseable": col_unparseable,
|
||||
"total": len(values),
|
||||
}
|
||||
|
||||
# Decide on serial vs threaded execution.
|
||||
n_workers = max(1, int(options.parallel_columns))
|
||||
col_results: list[dict] = []
|
||||
if n_workers == 1 or len(column_types) <= 1:
|
||||
for col, field_type in column_types.items():
|
||||
col_results.append(_process_column(col, field_type))
|
||||
else:
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
||||
futures = [
|
||||
pool.submit(_process_column, col, ft)
|
||||
for col, ft in column_types.items()
|
||||
]
|
||||
for fut in futures:
|
||||
col_results.append(fut.result())
|
||||
|
||||
# Merge per-column results back. Preserve the caller's column order
|
||||
# in ``column_types`` for both the output frame and the audit so
|
||||
# ``--parallel_columns`` doesn't reorder anything observable.
|
||||
result_by_col = {r["col"]: r for r in col_results}
|
||||
for col, _ft in column_types.items():
|
||||
r = result_by_col[col]
|
||||
out[col] = r["values"]
|
||||
cells_total += r["total"]
|
||||
cells_changed += r["changed"]
|
||||
cells_unparseable += r["unparseable"]
|
||||
for rec in r["records"]:
|
||||
if audit_room > 0:
|
||||
audit_records.append(rec)
|
||||
audit_room -= 1
|
||||
else:
|
||||
break
|
||||
|
||||
changes_df = pd.DataFrame(
|
||||
audit_records,
|
||||
|
||||
@@ -372,16 +372,21 @@ def _standardize_sentinels(
|
||||
Returns ``(new_df, change_records, total_replacements)``. ``change_records``
|
||||
is appended to the audit table so the user can see exactly which cells
|
||||
were converted from "N/A" / "-" / etc. to a real null.
|
||||
|
||||
Lazy-copy: rather than copying *df* up front, build per-column new
|
||||
value lists in a temporary dict and only materialise the output
|
||||
frame once we know at least one column actually changed. On clean
|
||||
files (no sentinels) we return *df* itself — a full-frame copy
|
||||
avoided, which on a 1 GB input saves a 1 GB allocation.
|
||||
"""
|
||||
out = df.copy()
|
||||
needles = {s.casefold(): s for s in sentinels}
|
||||
records: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
# Per-column replacements only — keyed by column name.
|
||||
changed_cols: dict[str, list[Any]] = {}
|
||||
|
||||
for col in columns:
|
||||
series = out[col]
|
||||
# Only iterate object/string columns — numeric/datetime cells can't
|
||||
# contain string sentinels by construction.
|
||||
series = df[col]
|
||||
if not (pdtypes.is_object_dtype(series) or pdtypes.is_string_dtype(series)):
|
||||
continue
|
||||
new_values: list[Any] = []
|
||||
@@ -420,7 +425,16 @@ def _standardize_sentinels(
|
||||
else:
|
||||
new_values.append(value)
|
||||
if changed:
|
||||
out[col] = new_values
|
||||
changed_cols[col] = new_values
|
||||
|
||||
if not changed_cols:
|
||||
# No sentinels found anywhere — return the input untouched and
|
||||
# let the caller decide whether downstream steps need a copy.
|
||||
return df, records, 0
|
||||
|
||||
out = df.copy()
|
||||
for col, vals in changed_cols.items():
|
||||
out[col] = vals
|
||||
return out, records, total
|
||||
|
||||
|
||||
@@ -726,13 +740,26 @@ def handle_missing(
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Sentinel standardization
|
||||
# ------------------------------------------------------------------
|
||||
# ``out`` is the working frame. We track whether we own it (i.e.,
|
||||
# it's safe to mutate) so we can defer the copy until the first
|
||||
# actual mutation. ``_standardize_sentinels`` is lazy-copy: it
|
||||
# returns the input itself when no sentinels were found.
|
||||
if options.standardize_sentinels and options.sentinels and columns:
|
||||
out, sentinel_records, sentinels_replaced = _standardize_sentinels(
|
||||
df, columns, options.sentinels,
|
||||
)
|
||||
records.extend(sentinel_records)
|
||||
out_is_owned = sentinels_replaced > 0
|
||||
else:
|
||||
out = df.copy()
|
||||
out = df
|
||||
out_is_owned = False
|
||||
|
||||
def _ensure_owned() -> None:
|
||||
"""Copy *df* on first mutation so we never write into the caller's frame."""
|
||||
nonlocal out, out_is_owned
|
||||
if not out_is_owned:
|
||||
out = out.copy()
|
||||
out_is_owned = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2 + 3. Drops (column-first, then row)
|
||||
@@ -741,6 +768,7 @@ def handle_missing(
|
||||
columns_dropped: list[str] = []
|
||||
global_strategy = options.strategy
|
||||
if global_strategy in _DROP_STRATEGIES:
|
||||
_ensure_owned()
|
||||
out, rows_dropped, columns_dropped = _apply_drops(
|
||||
out, columns, global_strategy, options, records,
|
||||
)
|
||||
@@ -756,8 +784,28 @@ def handle_missing(
|
||||
strat = _resolve_strategy(col, out[col], options)
|
||||
strategy_per_column[col] = strat
|
||||
if strat in _FILL_STRATEGIES:
|
||||
# Defer the copy until we know a fill will actually write.
|
||||
# ``_apply_fill`` early-returns 0 when the column has no
|
||||
# missing cells; we only need an owned frame when it would
|
||||
# otherwise mutate. Easier: ensure ownership before any
|
||||
# column that *could* fill, which is still cheaper than the
|
||||
# upfront copy when ``options.standardize_sentinels`` found
|
||||
# nothing AND the strategy is a no-op fill.
|
||||
_ensure_owned()
|
||||
cells_filled += _apply_fill(out, col, strat, options, records)
|
||||
|
||||
# No final blanket copy: when nothing mutated, the result *is* the
|
||||
# input. Callers must not assume identity-distinctness — this
|
||||
# mirrors pandas' own "views vs. copies" model. The buyer-facing
|
||||
# callers (GUI / CLI) only read the result, so this contract change
|
||||
# is benign and saves a 1 GB allocation on clean files.
|
||||
if not out_is_owned:
|
||||
logger.debug(
|
||||
"handle_missing: no mutations applied; returning input frame "
|
||||
"without copy (rows={}, cols={}).",
|
||||
len(out), len(out.columns),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build audit + after-profile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user