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
3 changes: 3 additions & 0 deletions faircode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Comment on lines +102 to +103

c = sub.add_parser("compare",
help="compare two datasets for representation drift")
Expand Down Expand Up @@ -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(",")]
Expand Down
26 changes: 17 additions & 9 deletions faircode/profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -128,15 +130,15 @@ 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 = []
for label, count in labels_counts.items():
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})
Comment on lines 138 to +141
# count desc, then label asc - deterministic tie-break so the JS port agrees.
groups.sort(key=lambda g: (-g["count"], g["label"]))

Expand Down Expand Up @@ -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
Expand All @@ -186,15 +188,15 @@ 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

# Categorical path (sex, race, geography, generic categorical, non-numeric age).
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

Expand Down Expand Up @@ -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}× "
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions faircode/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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'<tr class="{under}"><td>{esc(g["label"])}</td>'
f'<tr class="{" ".join(classes)}"><td>{esc(g["label"])}</td>'
f'<td class="num">{g["share"] * 100:.1f}%</td>'
f'<td class="num ci">{ci}</td>'
f'<td class="num">{g["count"]:,}</td>'
Expand Down Expand Up @@ -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; }}
Expand Down
20 changes: 20 additions & 0 deletions tests/test_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Comment on lines +105 to 107
from faircode.profiler import _r, _wilson

Expand Down
Loading