Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

## [Unreleased]

### Fixed
- `CRMCleaner.transform` no longer silently corrupts complex amounts into
wrong finite floats: cells holding actual `complex` values are masked to
NaN with a `UserWarning` naming them, and a column where nothing parses
(all-complex included) still raises `could not parse` per the documented
contract. Closes #129.
### Added
- **`models.GiftIntervalCalibrator`**: distribution-free intervals on a dollar
amount. Wraps an already-fitted regressor (`AskAmountRecommender`,
Expand Down
19 changes: 19 additions & 0 deletions philanthropy/preprocessing/_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from typing import Any

import warnings

import numpy as np
import pandas as pd

Expand Down Expand Up @@ -59,7 +61,24 @@ def _coerce_currency_to_float(col: pd.Series) -> pd.Series:
if pd.api.types.is_numeric_dtype(col):
return pd.to_numeric(col, errors="coerce").astype("float64")

# Complex values (e.g. np.complex128 cells that survive an object-cast
# retry, as when a formula column round-trips through Excel/openpyxl)
# must never reach the string path: str(3+4j) is "(3+4j)", which matches
# the parenthesised-negative rule and corrupts the cell into -34.0
# (#129). NaN + warning, consistent with "values that still don't parse
# become NaN".
had_value = col.notna()

if col.dtype == object:
complex_mask = col.map(lambda v: isinstance(v, complex)).fillna(False)
if complex_mask.any():
warnings.warn(
f"CRMCleaner: {int(complex_mask.sum())} complex value(s) in "
f"amount column {col.name!r} cannot be parsed as currency; "
f"they became NaN.",
stacklevel=2,
)
col = col.mask(complex_mask)
cleaned = col.astype(str).str.strip()
cleaned = cleaned.str.replace(r"^\((.*)\)$", r"-\1", regex=True)
cleaned = cleaned.str.replace(r"[^0-9eE.\-]", "", regex=True)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,39 @@ def test_amount_col_unparseable_column_raises(self):
with pytest.raises(ValueError, match="could not parse"):
cleaner.fit_transform(df)

def test_complex_amount_becomes_nan_with_warning_not_corruption(self):
# #129: a single np.complex128 cell (Excel/openpyxl formula column
# round-trip) used to slip through the object-cast retry and corrupt
# into a plausible finite float — str(3+4j) == "(3+4j)" matched the
# parenthesised-negative rule, yielding -34.0. Contract now: in a
# column with other parseable values, the complex cell becomes NaN
# with a warning, consistent with "values that still don't parse
# become NaN".
X_fit = pd.DataFrame({
"gift_date": ["2023-01-01", "2023-02-01"],
"gift_amount": [5.0, 10.0],
})
cleaner = CRMCleaner().set_output(transform="pandas").fit(X_fit)
X_bad = pd.DataFrame({
"gift_date": ["2023-03-01", "2023-04-01"],
"gift_amount": pd.Series([np.complex128(3 + 4j), 25.0], dtype=object),
})
with pytest.warns(UserWarning, match="complex value"):
out = cleaner.transform(X_bad)
assert np.isnan(out["gift_amount"].iloc[0])
assert out["gift_amount"].iloc[1] == 25.0

def test_complex_whole_column_still_raises(self):
# A column where nothing parses still raises, complex cells included:
# masking must not swallow the all-unparseable contract.
df = pd.DataFrame({
"gift_date": ["2023-03-01"],
"gift_amount": pd.Series([np.complex128(3 + 4j)], dtype=object),
})
cleaner = CRMCleaner()
with pytest.raises(ValueError, match="could not parse"):
cleaner.fit_transform(df)

def test_date_col_coerced_to_datetime(self):
df = pd.DataFrame({"gift_date": ["2023-07-01"], "gift_amount": [100.0]})
cleaner = CRMCleaner().set_output(transform="pandas")
Expand Down