From 474a6049bda7814bb3a9c7860bbc7008937b911e Mon Sep 17 00:00:00 2001 From: Cristobal Santana Date: Sat, 4 Jul 2026 17:26:30 -0400 Subject: [PATCH] fix: count/nunique aggregates yield 0, not NaN, for parents without children --- src/auto_bayesian/materialize.py | 14 ++++++++- tests/test_materialize.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/auto_bayesian/materialize.py b/src/auto_bayesian/materialize.py index 8f384f0..0852e7d 100644 --- a/src/auto_bayesian/materialize.py +++ b/src/auto_bayesian/materialize.py @@ -54,6 +54,18 @@ def materialize_project( left_on=relation.parent_key, right_on=relation.parent_key, ) + count_columns = [ + aggregate.output_name(relation.child) + for aggregate in relation.aggregations + if aggregate.op in ("count", "nunique") + ] + if count_columns: + # A parent with no child rows has a count of 0, not a missing + # value. Left NaN, these zeros would later be binned as + # "__missing__" and become indistinguishable from genuinely + # absent data. Other ops (mean, latest, ...) stay NaN, since + # they are truly undefined over an empty set. + parent[count_columns] = parent[count_columns].fillna(0) if relation.sequence_features: seq = _compute_sequence_features(child, relation, project.tables[relation.child]) parent = parent.merge( @@ -257,4 +269,4 @@ def _compute_acceleration(gaps: pd.Series) -> float: if older_mean == 0 and recent_mean == 0: return 0.0 denom = older_mean if older_mean != 0 else 1.0 - return float((older_mean - recent_mean) / denom) + return float((older_mean - recent_mean) / denom) \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e5dc67a..46c57bc 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -22,3 +22,53 @@ def test_materialize_project_joins_and_aggregates() -> None: assert lead_2["interaction_count"] == 2 assert lead_2["channel_count"] == 2 assert lead_2["latest_channel"] == "meeting" + + +def test_one_to_many_count_is_zero_for_parents_without_children(tmp_path: Path) -> None: + import pandas as pd + + from auto_bayesian import build_project + + parents = pd.DataFrame( + {"tender_id": ["T1", "T2", "T3"], "won": ["1", "0", "0"]} + ) + children = pd.DataFrame( + { + "bid_id": ["B1", "B2", "B3"], + "tender_id": ["T1", "T1", "T2"], + "bidder": ["a", "b", "a"], + "price": [0.9, 1.1, 0.95], + } + ) + project = build_project( + root=tmp_path, + root_table="tenders", + target_column="won", + positive_label="1", + output_dir=tmp_path / "artifacts", + tables=[ + {"name": "tenders", "primary_key": "tender_id"}, + {"name": "bids", "primary_key": "bid_id"}, + ], + relations=[ + { + "parent": "tenders", + "child": "bids", + "parent_key": "tender_id", + "child_key": "tender_id", + "kind": "one_to_many", + "aggregations": [ + {"op": "count", "name": "bid_count"}, + {"column": "bidder", "op": "nunique", "name": "bidder_count"}, + {"column": "price", "op": "mean", "name": "mean_price"}, + ], + } + ], + ) + frame = materialize_project(project, tables={"tenders": parents, "bids": children}) + childless = frame.loc[frame["tender_id"] == "T3"].iloc[0] + + assert childless["bid_count"] == 0 + assert childless["bidder_count"] == 0 + assert pd.isna(childless["mean_price"]) + assert frame.loc[frame["tender_id"] == "T1"].iloc[0]["bid_count"] == 2 \ No newline at end of file