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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **``evaluate`` no longer raises on a constant column.** Binning a column with no spread
(every value identical) returned the documented no-spread result for ``equal_freq`` but
raised ``ValueError: Bin edges must be unique`` from ``pd.cut`` for ``equal_width`` and
``sd``, because their fitted edges were identical yet finite and slipped past the guard.
The derivations module promises ``evaluate`` never raises on a routine state; a constant
column is one. All three methods now return the same no-spread result, whose ``fitted``
dict also carries ``n_bins: 0``, ``edges: []`` and ``labels: []`` so callers see one
shape. Explicit ``breaks`` on a constant column still bin. Found through the app on a
single-time-point file whose TIME column was a constant.

## [0.2.1] - 2026-09-09

### Fixed
Expand Down
18 changes: 15 additions & 3 deletions processbehavior/derivations.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,23 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:

edges, fit_msg = _fit_edges(spec, x[present])

# Degenerate fit (no spread) — cannot bin.
if len(edges) < 2 or any(not math.isfinite(e) for e in edges[1:-1]):
# Degenerate fit (no spread) — cannot bin. A constant column produces three shapes,
# one per method: a single edge (equal_freq, after np.unique collapses the quantiles),
# identical finite edges (equal_width, linspace(lo, lo)), and a zero-sigma sd fit
# ([-inf, mu, mu, mu, mu, inf]). pd.cut raises "Bin edges must be unique" on the latter
# two, and evaluate() promises never to raise on a routine state, so all three return
# the same no-spread result. User-supplied breaks are validated ascending elsewhere and
# bin a constant column fine (everything lands in one interval), so they never trip this.
degenerate = (
len(edges) < 2
or any(not math.isfinite(e) for e in edges[1:-1])
or any(b <= a for a, b in zip(edges, edges[1:], strict=False)) # not strictly increasing
)
if degenerate:
return EvalResult(
values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
n_invalid=0, invalid_index=empty_index, fitted={'method': params['method']},
n_invalid=0, invalid_index=empty_index,
fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []},
message='column has no spread; cannot bin',
)

Expand Down
34 changes: 34 additions & 0 deletions tests/test_derivations.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,40 @@ def test_tie_drop_uses_fitted_count_labels_and_message():
assert 'requested 5 bins' in r.message and 'produced 3' in r.message


@pytest.mark.parametrize('method', ['equal_freq', 'equal_width', 'sd'])
def test_constant_column_is_no_spread_for_every_method(method):
"""A constant column is a routine state, so evaluate() returns rather than raises.

Only equal_freq used to reach the no-spread branch (np.unique collapses its quantiles to
one edge). equal_width fits identical finite edges and sd fits a zero-sigma set of edges;
both slipped past the guard and pd.cut raised "Bin edges must be unique". Found through
the app, on a T=1 file whose TIME column is a constant.
"""
const = pd.Series([1.0] * 30)
r = evaluate(Derivation.bin('t', method=method, n=4), const)
assert r.message == 'column has no spread; cannot bin'
assert r.fitted['method'] == method
assert r.fitted['n_bins'] == 0 and r.fitted['edges'] == [] and r.fitted['labels'] == []
assert r.values.isna().all() and len(r.values.cat.categories) == 0
assert r.n_invalid == 0


def test_constant_column_with_explicit_breaks_still_bins():
"""Breaks are the analyst's own cut points: a constant column bins into one of them."""
r = evaluate(Derivation.bin('t', method='breaks', breaks=[0.5, 1.5]), pd.Series([1.0] * 30))
assert r.fitted['n_bins'] == 3
assert r.values.notna().all() and r.values.nunique() == 1


def test_constant_column_validates_without_raising():
"""validate() evaluates internally; it must survive the no-spread result too."""
df = pd.DataFrame({'t': [1.0] * 30})
for method in ('equal_freq', 'equal_width', 'sd'):
check = validate(Derivation.bin('t', method=method, n=4, bin_labels=['a', 'b']), df)
# No label_count complaint against zero bins is not required either way; it must not raise.
assert isinstance(check.ok, bool)


def test_range_labels_from_fitted_edges():
r = evaluate(Derivation.bin('w', method='equal_width', n=2, bin_labels='range'),
pd.Series([0.0, 10.0]))
Expand Down
Loading