"""Standalone witness: the Statlog labels used by data_prep.py do not match the
corrected code table for these data (Groemping 2019, Report 4/2019).
Requires only pandas, scipy and scikit-learn. Fetches credit-g from OpenML.
Nothing here depends on this repository; the crosswalk is derived from published
level frequencies, not assumed.
"""
import pandas as pd
from scipy.stats import spearmanr
from sklearn.datasets import fetch_openml
from sklearn.linear_model import LinearRegression, LogisticRegression
N_BAD, N_GOOD = 300, 700
# Groemping (2019) Table 1: percentage of the 300 bad and 700 good risks in each
# corrected level, for the four categorical variables data_prep.py consumes.
TABLE1 = {
"checking_status": [
(1, "no checking account", 45.00, 19.86),
(2, "< 0 DM", 35.00, 23.43),
(3, "0 <= ... < 200 DM", 4.67, 7.00),
(4, ">= 200 DM / salary for at least 1 year", 15.33, 49.71)],
"savings_status": [
(1, "unknown / no savings account", 72.33, 55.14),
(2, "< 100 DM", 11.33, 9.86),
(3, "100 <= ... < 500 DM", 3.67, 7.43),
(4, "500 <= ... < 1000 DM", 2.00, 6.00),
(5, ">= 1000 DM", 10.67, 21.57)],
"housing": [
(1, "for free", 23.33, 15.57),
(2, "rent", 62.00, 75.43),
(3, "own", 14.67, 9.00)],
"personal_status": [
(1, "male : divorced/separated", 6.67, 4.29),
(2, "female : non-single OR male : single", 36.33, 28.71),
(3, "male : married/widowed", 48.67, 57.43),
(4, "female : single", 8.33, 9.57)],
}
bunch = fetch_openml("credit-g", version=1, as_frame=True, parser="auto")
df = bunch.data.copy()
df["good"] = (bunch.target == "good").astype(int)
print("Deriving the crosswalk by matching (bad, good) level counts.\n")
crosswalk = {}
for var, levels in TABLE1.items():
obs = df.groupby(var, observed=True)["good"].agg(good="sum", n="size")
obs["bad"] = obs["n"] - obs["good"]
print(f"--- {var}")
crosswalk[var] = {}
for code, corrected, pb, pg in levels:
tb, tg = round(pb * N_BAD / 100), round(pg * N_GOOD / 100)
hits = [l for l, r in obs.iterrows()
if abs(r["bad"] - tb) <= 1 and abs(r["good"] - tg) <= 1]
assert len(hits) == 1, f"{var} code {code}: {len(hits)} matches"
crosswalk[var][hits[0]] = (code, corrected)
print(f" distributed {hits[0]!r:22} n={int(obs.loc[hits[0],'n']):4d}"
f" -> code {code}: {corrected}")
print()
# 1. the ordinal maps in data_prep.py against the corrected order
DISTRIBUTED = {
"checking_status": {"no checking": 0, "<0": 1, "0<=X<200": 2, ">=200": 3},
"savings_status": {"no known savings": 0, "<100": 1, "100<=X<500": 2,
"500<=X<1000": 3, ">=1000": 4},
"housing": {"for free": 0, "rent": 1, "own": 2},
}
CORRECTED = {v: {lbl: code - 1 for lbl, (code, _) in m.items()}
for v, m in crosswalk.items() if v in DISTRIBUTED}
print("Monotonicity of the ordinal scale, before and after correction:")
for var in DISTRIBUTED:
for name, m in (("as coded in data_prep", DISTRIBUTED[var]),
("corrected order ", CORRECTED[var])):
code = df[var].map(m).astype(float) # cast: mapping a Categorical keeps
r = spearmanr(code, df["good"]) # the original category order
by = df.assign(c=code).groupby("c")["good"].mean().round(3).tolist()
print(f" {var:16} {name} rho={r.statistic:+.3f} p={r.pvalue:.2e} P(good) by code {by}")
print()
# 2. the effect on a structural coefficient of the outcome equation
SEX = {"male div/sep": 1, "female div/dep/mar": 0, "male single": 1, "male mar/wid": 1}
PARENTS = ["A", "C", "S1", "S2", "S3", "R1", "R2"]
def fit_beta(maps):
d = pd.DataFrame({
"A": df["personal_status"].map(SEX).astype(float),
"C": df["age"].astype(float),
"S1": df["checking_status"].map(maps["checking_status"]).astype(float),
"S2": df["savings_status"].map(maps["savings_status"]).astype(float),
"S3": df["housing"].map(maps["housing"]).astype(float),
"R1": df["credit_amount"].astype(float),
"R2": df["duration"].astype(float),
"Y": df["good"]})
m = LogisticRegression(max_iter=1000).fit(d[PARENTS].values, d["Y"].values)
return dict(zip(PARENTS, m.coef_[0])), float(m.score(d[PARENTS].values, d["Y"].values))
b_dist, acc_dist = fit_beta(DISTRIBUTED)
b_corr, acc_corr = fit_beta(CORRECTED)
print("Outcome equation Y ~ A + C + S1 + S2 + S3 + R1 + R2, full sample:")
print(f" beta_S1 as coded {b_dist['S1']:+.4f} corrected {b_corr['S1']:+.4f}")
print(f" beta_S2 as coded {b_dist['S2']:+.4f} corrected {b_corr['S2']:+.4f}")
print(f" beta_S3 as coded {b_dist['S3']:+.4f} corrected {b_corr['S3']:+.4f}")
print(f" accuracy as coded {acc_dist:.4f} corrected {acc_corr:.4f}")
# 3. the protected attribute
print("\nSEX_MAP against the corrected code table:")
corrected_sex = {"male div/sep": 1, "female div/dep/mar": None,
"male single": 1, "male mar/wid": 0}
n_flip = n_amb = 0
for lbl, (code, corrected) in crosswalk["personal_status"].items():
n = int((df["personal_status"] == lbl).sum())
used, truth = SEX[lbl], corrected_sex[lbl]
status = "unrecoverable" if truth is None else ("agrees" if used == truth else "FLIPPED")
n_amb += n if truth is None else 0
n_flip += n if (truth is not None and used != truth) else 0
print(f" {lbl!r:22} n={n:4d} SEX_MAP={used} corrected={truth} {status}")
print(f"\n sex label wrong for {n_flip} rows ({100*n_flip/len(df):.1f}%),"
f" unrecoverable for {n_amb} ({100*n_amb/len(df):.1f}%),"
f" indefensible for {n_flip+n_amb} ({100*(n_flip+n_amb)/len(df):.1f}%)")
data_prep.py: the Statlog labels for A, S1, S2 and S3 do not match the corrected code table for these dataHi again, and thanks again for keeping this open source. This is a separate
finding from #7 and I think it is the more consequential of the two, so I wanted
to put the evidence in front of you before doing anything else with it.
Short version: the English labels distributed with
credit-gare not the labelsthat belong to the numeric codes in the German Credit data. All four categorical
variables that
data_prep.pymaps into Chiappa's SCM are affected, including theprotected attribute.
Where this comes from
Grömping recovered the original code table for these data from the German-language
sources (Häußler 1979/1981; Fahrmeir and Hamerle 1981/1984) and reports that the
table distributed with the Statlog version is wrong. She deposited corrected data
as South German Credit and states that her code table "can also be used as a
corrected coding for the Statlog German credit data", with the single caveat that
people_liableandforeign_workerneed their levels switched.Data Set. Report 4/2019, Beuth University of Applied Sciences Berlin.
http://www1.beuth-hochschule.de/FB_II/reports/Report-2019-004.pdf
The correspondence is derived, not assumed
I did not want to take the mapping on trust, so it is derived. Her Table 1 gives,
for each level, the percentage of the 300 bad and 700 good risks falling in it,
which recovers the level counts exactly. Matching each corrected level to the
credit-gcategory with the same (bad, good) count pair gives a unique match forall 11 categorical variables, with a tolerance of one count for rounding.
For the four variables
data_prep.pyuses:checking_status<00<=X<200>=200no checkingsavings_status<100100<=X<500500<=X<1000>=1000no known savingshousingrentownfor freepersonal_statusmale div/sepfemale div/dep/marmale singlemale mar/widAcross all 11 categorical variables, 24 of 42 levels are relabelled.
Why it matters here, concretely
1.
CHECKING_MAPandSAVINGS_MAPimpose an order the data contradicts.Under the distributed labels, P(good) by
S1code runs 0.883, 0.507, 0.610,0.778, which no monotone scale fits, and Spearman's rho between code and outcome
is -0.228. Under the corrected levels the same four groups run 0.507, 0.610,
0.778, 0.883, monotone increasing, rho +0.348. For savings the distributed
coding gives rho -0.022 (p = 0.49, no association at all) against +0.175
(p = 2.5e-8) corrected.
This is exactly the implausibility Grömping reports noticing first: with the
distributed labels, creditworthiness appears to improve when a debtor is moved
to "no checking account".
2. It changes the sign of a structural coefficient. Fitting
Y ~ A + C + S1 + S2 + S3 + R1 + R2on the full sample, beta_S1 is -0.442with the current maps and +0.619 with the corrected order. Accuracy goes from
0.711 to 0.741. On the repo's own 70/30 split the same flip is -0.416 to +0.624,
and it holds in all of ten random splits with no overlap. Every interventional
and counterfactual distribution in
perception.pyinherits this.3.
SEX_MAPcannot be repaired. Under the corrected table,male mar/wid(n = 92) is female : single, so those labels are inverted; and
female div/dep/mar(n = 310) contains both female non-singles and male singles,so it does not determine sex at all. That is 9.2% of rows with an inverted sex
label and 31.0% unrecoverable, 40.2% in total. Grömping notes the same thing
from the other side: the data as distributed appear to contain no single females,
which is what first indicated the problem.
For the fairness framing this is the sharpest consequence. Restricting to the 690
records whose sex the corrected table does determine,
SEX_MAPlabels every oneof them male, so there is no protected group at all and
do(A=0)has no supportin the sample.
One honest caveat: the correction does not improve everything. For
housingitgoes the other way, rho +0.134 as coded against +0.024 (p = 0.45) corrected, so
the correction removes an apparent association rather than recovering one.
Reproduction
Self-contained, no dependency on this repo beyond
pandas,scipyandscikit-learn. It derives the crosswalk from the published frequencies ratherthan hard-coding it, and asserts the match is unique.
repro_codetable.pyWhat would you like done with this?
I am not going to send a patch that silently rewrites
CHECKING_MAP,SAVINGS_MAP,HOUSING_MAPandSEX_MAP, because the choice is yours and it changes everypublished number. Some options, in increasing order of intrusiveness:
data/README.mdand docstrings recordingthe discrepancy and citing the report, changing no behaviour.
plus a test asserting the crosswalk still resolves uniquely, defaults unchanged.
I am happy to open any of these, or none. I have signed the CLA. Related to #7,
though independent of it: #7 is about what the distance metrics can see, this is
about what the variables are.