NinueNAODD-repro / code /rater_quality.py
ProCreations's picture
Claims 2 and 6: executed BBQ vs Crowd-BT timing and rater-quality correlation
60da8fb verified
Raw
History Blame Contribute Delete
6.17 kB
"""Claim 6: BBQ rater-quality parameter q_r vs rater agreement (IHQ unscreened).
Measures the Pearson correlation between the EM-fitted mixture weight q_r
(Eq. 2/Eq. 12) and each rater's agreement with the final ranking, under nine
definitions of 'agreement' and three choices of 'final ranking'. Adds a
destructive control (permuted q_r) and the operational test the claim makes:
whether q_r alone separates the hand-screened raters from the unscreened ones
on the pooled IHQ-all data.
"""
import json
import numpy as np
from scipy import stats
from bbq_vs_crowdbt import bbq_em, load_clic, encode
def consensus(df):
i, j, r, K, R = encode(df)
lam, q, its, hist = bbq_em(np.ones(i.size), i, j, r, K, R)
items = sorted(set(df.winner) | set(df.loser))
return items, lam, q, {v: k for k, v in enumerate(items)}, (i, j, r)
def rater_agreement(df, rank_of, mode):
"""Per-rater agreement with a reference ranking (higher rank value = better)."""
out = {}
for rater, g in df.groupby("rater"):
w = g.winner.to_numpy()
l = g.loser.to_numpy()
rw = np.array([rank_of[x] for x in w])
rl = np.array([rank_of[x] for x in l])
keep = rw != rl
if mode == "all_pairs":
correct = (rw > rl)
n = correct.size
else: # drop reference-ties
correct = (rw[keep] > rl[keep])
n = correct.size
out[rater] = np.nan if n == 0 else correct.mean()
return out
def main():
res = {}
scr = load_clic("screened")
uns = load_clic("unscreened")
allc = __import__("pandas").concat([scr, uns], ignore_index=True)
# --- fits
it_u, lam_u, q_u, map_u, _ = consensus(uns)
it_s, lam_s, q_s, map_s, _ = consensus(scr)
it_a, lam_a, q_a, map_a, _ = consensus(allc)
refs = {
"BBQ consensus on IHQ-unscreened": (it_u, lam_u),
"BBQ consensus on IHQ-screened": (it_s, lam_s),
"BBQ consensus on IHQ-all": (it_a, lam_a),
}
raters_u = sorted(set(uns.rater))
qmap_u = dict(zip(sorted(set(uns.rater)), q_u))
rows = []
for rname, (items, lam) in refs.items():
rank_of = {it: float(l) for it, l in zip(items, lam)}
for mode in ["all_pairs", "drop_ref_ties"]:
agree = rater_agreement(uns, rank_of, mode)
xs = np.array([qmap_u[r] for r in raters_u])
ys = np.array([agree[r] for r in raters_u])
m = np.isfinite(ys)
pr = stats.pearsonr(xs[m], ys[m])
sp = stats.spearmanr(xs[m], ys[m])
rows.append(dict(reference=rname, agreement=mode, n_raters=int(m.sum()),
pearson_r=round(float(pr.statistic), 4),
pearson_p=float(pr.pvalue),
spearman_rho=round(float(sp.statistic), 4)))
res["correlations"] = rows
# --- same, on the screened split (paper says unscreened shows the effect)
qmap_s = dict(zip(sorted(set(scr.rater)), q_s))
rank_of = {it: float(l) for it, l in zip(it_s, lam_s)}
ag_s = rater_agreement(scr, rank_of, "all_pairs")
rs = sorted(set(scr.rater))
xs = np.array([qmap_s[r] for r in rs]); ys = np.array([ag_s[r] for r in rs])
m = np.isfinite(ys)
res["screened_split_pearson_r"] = round(float(stats.pearsonr(xs[m], ys[m]).statistic), 4)
res["screened_split_n"] = int(m.sum())
# --- destructive control: permute q_r
rank_of = {it: float(l) for it, l in zip(it_u, lam_u)}
agree = rater_agreement(uns, rank_of, "all_pairs")
xs = np.array([qmap_u[r] for r in raters_u])
ys = np.array([agree[r] for r in raters_u])
m = np.isfinite(ys)
rng = np.random.default_rng(20260802)
perm = [float(stats.pearsonr(rng.permutation(xs[m]), ys[m]).statistic) for _ in range(2000)]
res["permutation_control"] = dict(
observed=round(float(stats.pearsonr(xs[m], ys[m]).statistic), 4),
perm_mean=round(float(np.mean(perm)), 4),
perm_abs_max=round(float(np.max(np.abs(perm))), 4),
perm_p=float((np.sum(np.array(perm) >= stats.pearsonr(xs[m], ys[m]).statistic) + 1) / 2001))
# --- operational test: does q_r alone recover the hand-screening label?
scr_set, uns_set = set(scr.rater), set(uns.rater)
raters_a = sorted(set(allc.rater))
qmap_a = dict(zip(raters_a, q_a))
lab = np.array([1 if r in scr_set else 0 for r in raters_a])
qv = np.array([qmap_a[r] for r in raters_a])
u = stats.mannwhitneyu(qv[lab == 1], qv[lab == 0], alternative="greater")
auc = float(u.statistic / ((lab == 1).sum() * (lab == 0).sum()))
res["screening_recovery"] = dict(
n_screened=int((lab == 1).sum()), n_unscreened=int((lab == 0).sum()),
mean_q_screened=round(float(qv[lab == 1].mean()), 4),
mean_q_unscreened=round(float(qv[lab == 0].mean()), 4),
auc_q_predicts_screened=round(auc, 4), mwu_p=float(u.pvalue))
# --- operational test 2: drop the lowest-q raters from unscreened and see
# whether the resulting consensus moves toward the hand-screened consensus.
base_rank = np.argsort(np.argsort(-np.array([float(l) for l in lam_s])))
s_order = {it: float(l) for it, l in zip(it_s, lam_s)}
def tau_to_screened(sub):
items, lam, q, _, _ = consensus(sub)
common = [x for x in items if x in s_order]
a = np.array([float(lam[items.index(x)]) for x in common])
b = np.array([s_order[x] for x in common])
return float(stats.kendalltau(a, b).statistic)
drops = []
order = np.argsort(np.array([qmap_u[r] for r in raters_u]))
for k in [0, 5, 10, 15, 20]:
bad = set(np.array(raters_u)[order[:k]])
sub = uns[~uns.rater.isin(bad)]
drops.append(dict(dropped=k, kept_raters=int(sub.rater.nunique()),
comparisons=int(len(sub)),
kendall_to_screened=round(tau_to_screened(sub), 4)))
res["low_quality_drop"] = drops
res["claimed_r"] = 0.724
print(json.dumps(res, indent=1))
with open("rater_quality.json", "w") as f:
json.dump(res, f, indent=1)
if __name__ == "__main__":
main()