diff --git a/faircode/cli.py b/faircode/cli.py index 027083e..1825b5a 100644 --- a/faircode/cli.py +++ b/faircode/cli.py @@ -99,6 +99,8 @@ def main(argv: list[str] | None = None) -> int: help="imbalance-ratio flag threshold (default 3.0)") p.add_argument("--missing-flag", type=float, metavar="F", help="missing-data flag threshold (default 0.05)") + p.add_argument("--min-group-size", type=int, default=100, metavar="N", + help="warn when a subgroup has fewer than N rows (default: 100)") c = sub.add_parser("compare", help="compare two datasets for representation drift") @@ -133,6 +135,7 @@ def main(argv: list[str] | None = None) -> int: "intersection_floor": args.intersection_floor, "imbalance_flag": args.imbalance_flag, "missing_flag": args.missing_flag, + "min_group_size": args.min_group_size, } if args.cross: parts = [c.strip() for c in args.cross.split(",")] diff --git a/faircode/profiler.py b/faircode/profiler.py index 5a66c59..5a14fe1 100644 --- a/faircode/profiler.py +++ b/faircode/profiler.py @@ -22,6 +22,7 @@ REFERENCE_DEVIATION_FLAG = 0.05 # under-representation vs a reference baseline AGE_BANDS = [0, 18, 30, 45, 60, 75] # left-closed edges; final band is "75+" MAX_DIMENSION_GROUPS = 50 # drop identifier/date-like columns (geography exempt) +MIN_GROUP_SIZE = 100 # warn when a subgroup has fewer than N rows (default: 100) _DATE_RE = re.compile(r"\d{1,4}[/-]\d{1,2}[/-]\d{1,4}") @@ -57,6 +58,7 @@ def _wilson(count: int, n: int) -> tuple: "imbalance_flag": IMBALANCE_FLAG, "missing_flag": MISSING_FLAG, "reference_flag": REFERENCE_DEVIATION_FLAG, + "min_group_size": MIN_GROUP_SIZE, # warn when a subgroup has fewer than N rows "cross": None, # [colA, colB] to force the intersection pair (SPEC 4) "reference": None, # {column: {group: expected_share}} baseline (SPEC 8) } @@ -128,7 +130,7 @@ def _skewness(values: list[float]): # ── Per-dimension metrics (SPEC section 3) ────────────────────────────────── def _analyze_groups(labels_counts: dict, n_total: int, null_count: int, - skewness=None, min_share_threshold=MIN_SHARE_THRESHOLD) -> dict: + skewness=None, min_share_threshold=MIN_SHARE_THRESHOLD, min_group_size=MIN_GROUP_SIZE) -> dict: """Given {label: count} for non-null values, compute the dimension metrics.""" n_nonnull = sum(labels_counts.values()) groups = [] @@ -136,7 +138,7 @@ def _analyze_groups(labels_counts: dict, n_total: int, null_count: int, share = count / n_nonnull if n_nonnull else 0.0 lo, hi = _wilson(count, n_nonnull) groups.append({"label": str(label), "count": int(count), "share": share, - "ci_low": _r(lo, 4), "ci_high": _r(hi, 4)}) + "ci_low": _r(lo, 4), "ci_high": _r(hi, 4),"small_group": count < min_group_size}) # count desc, then label asc - deterministic tie-break so the JS port agrees. groups.sort(key=lambda g: (-g["count"], g["label"])) @@ -169,7 +171,7 @@ def _analyze_groups(labels_counts: dict, n_total: int, null_count: int, def _dimension(df: pd.DataFrame, name: str, kind: str, - min_share=MIN_SHARE_THRESHOLD) -> dict: + min_share=MIN_SHARE_THRESHOLD, min_group_size=MIN_GROUP_SIZE) -> dict: col = df[name] n_total = len(df) skewness = None @@ -186,7 +188,7 @@ def _dimension(df: pd.DataFrame, name: str, kind: str, for b in bands: if b is not None: counts[b] = counts.get(b, 0) + 1 - result = _analyze_groups(counts, n_total, null_count, skewness, min_share) + result = _analyze_groups(counts, n_total, null_count, skewness, min_share, min_group_size) result.update({"name": name, "kind": kind}) return result @@ -194,7 +196,7 @@ def _dimension(df: pd.DataFrame, name: str, kind: str, null_count = int(col.isna().sum()) vc = col.dropna().value_counts() counts = {label: int(c) for label, c in vc.items()} - result = _analyze_groups(counts, n_total, null_count, skewness, min_share) + result = _analyze_groups(counts, n_total, null_count, skewness, min_share, min_group_size) result.update({"name": name, "kind": kind}) return result @@ -294,6 +296,11 @@ def _build_flags(dimensions: list[dict], intersections: list[dict], f"{d['name']}: '{g['label']}' is under-represented " f"({g['share'] * 100:.1f}%)" ) + if g.get("small_group"): + flags.append( + f"{d['name']}: '{g['label']}' has only {g['count']} rows; " + f"fairness metrics may be unreliable" + ) if d["imbalance_ratio"] is not None and d["imbalance_ratio"] >= imbalance_flag: flags.append( f"{d['name']}: imbalance ratio {d['imbalance_ratio']:.1f}× " @@ -365,14 +372,15 @@ def profile(df: pd.DataFrame, overrides=None, opts=None) -> dict: column's dimension when auto-detection misses or mislabels it. `opts` is an optional dict of tunable knobs (SPEC section 7): `min_share`, - `intersection_floor`, `imbalance_flag`, `missing_flag`, `reference_flag`, a - `cross` pair [colA, colB] for the intersection (SPEC 4), and a `reference` - baseline {column: {group: expected_share}} (SPEC 8). + `intersection_floor`, `imbalance_flag`, `missing_flag`, `reference_flag`, + `min_group_size`, a `cross` pair [colA, colB] for the intersection (SPEC 4), + and a `reference` baseline {column: {group: expected_share}} (SPEC 8). + """ overrides = overrides or {} o = _resolve_opts(opts) detected = detect_columns(df, overrides) - dimensions = [_dimension(df, d["name"], d["kind"], o["min_share"]) + dimensions = [_dimension(df, d["name"], d["kind"], o["min_share"], o["min_group_size"]) for d in detected] # Drop identifier/date-like columns that exploded into many groups; geography # (cities, regions) legitimately has high cardinality, so it is exempt - as is diff --git a/faircode/report.py b/faircode/report.py index ddac255..22d3c27 100644 --- a/faircode/report.py +++ b/faircode/report.py @@ -49,11 +49,12 @@ def to_terminal(result: dict) -> str: shown = d["groups"][:DISPLAY_GROUPS] for g in shown: mark = " <- under-represented" if g["label"] in d["under_represented"] else "" + warning = (" ⚠ small group (metric may be unreliable)" if g.get("small_group") else "") ci = "" if g.get("ci_low") is not None and g.get("ci_high") is not None: ci = f" [95% CI {g['ci_low'] * 100:.1f}-{g['ci_high'] * 100:.1f}%]" add(f" {g['label'][:18]:<18} {_bar(g['share'])} " - f"{g['share'] * 100:5.1f}% (n={g['count']:,}){ci}{mark}") + f"{g['share'] * 100:5.1f}% (n={g['count']:,}){ci}{mark}{warning}") if len(d["groups"]) > DISPLAY_GROUPS: add(f" … and {len(d['groups']) - DISPLAY_GROUPS} more groups") meta = [] @@ -162,12 +163,16 @@ def esc(s) -> str: for d in result["dimensions"]: rows = [] for g in d["groups"][:DISPLAY_GROUPS]: - under = "under" if g["label"] in d["under_represented"] else "ok" + classes = [] + if g["label"] in d["under_represented"]: + classes.append("under") + if g.get("small_group"): + classes.append("small-group") ci = "" if g.get("ci_low") is not None and g.get("ci_high") is not None: ci = f'{g["ci_low"] * 100:.1f}–{g["ci_high"] * 100:.1f}%' rows.append( - f'{esc(g["label"])}' + f'{esc(g["label"])}' f'{g["share"] * 100:.1f}%' f'{ci}' f'{g["count"]:,}' @@ -207,6 +212,7 @@ def esc(s) -> str: td.bar span {{ display:block; height:10px; background:var(--accent3); border-radius:3px; }} tr.under td.bar span {{ background:var(--accent); }} tr.under td:first-child::after {{ content:' (under-represented)'; color:var(--accent); font-size:11px; }} + tr.small-group td:first-child::before {{content:'⚠ small group ';color:var(--accent);}} .flags ul {{ list-style:none; padding:0; }} .flags li {{ background:#fbeae3; border-left:3px solid var(--accent); padding:8px 12px; margin:6px 0; border-radius:0 4px 4px 0; }} .head {{ border-bottom:2px solid var(--accent); padding-bottom:12px; }} diff --git a/tests/test_profiler.py b/tests/test_profiler.py index 510f1c3..1d53761 100644 --- a/tests/test_profiler.py +++ b/tests/test_profiler.py @@ -83,7 +83,27 @@ def test_skewed_distribution_flags_under_represented(): assert dim["dimension_score"] < 50 assert any("under-represented" in f for f in result["flags"]) +def test_min_group_size_tunable(): + df = pd.DataFrame({"sex": ["M"] * 80 + ["F"] * 20}) + # Default threshold (100): both groups are considered small. + result = profile(df) + dim = result["dimensions"][0] + + assert all(g["small_group"] for g in dim["groups"]) + + flags = result["flags"] + assert any("'M'" in f and "unreliable" in f for f in flags) + assert any("'F'" in f and "unreliable" in f for f in flags) + + # Lowering the threshold means neither group is considered small. + result = profile(df, opts={"min_group_size": 10}) + dim = result["dimensions"][0] + + assert not any(g["small_group"] for g in dim["groups"]) + + flags = result["flags"] + assert not any("fairness metrics may be unreliable" in f for f in flags) def test_group_shares_carry_wilson_ci(): from faircode.profiler import _r, _wilson