Plaiglab / plagdetect /datafraud.py
SanidhyaDhangar's picture
PlaigLab β€” Hugging Face Space (Docker) clean deploy
ebebfe8
Raw
History Blame Contribute Delete
4.89 kB
"""DATA FRAUD detection β€” statistical fingerprints of
fabricated numbers. Text plagiarism tools ignore this entirely.
BENFORD'S LAW : in genuine natural datasets the leading digit follows
P(d) = log10(1 + 1/d) (1 appears ~30%, 9 ~4.6%).
Fabricated/manually-invented numbers are too uniform ->
chi-square + MAD divergence from Benford flags them.
GRIM TEST : a reported mean of integer-valued items over N items must
be one of only N possible fractions. A mean that is
arithmetically impossible for its sample size is a red flag
for fabricated or mistyped summary statistics.
Pure numpy, no dependencies. Returns evidence, never a hard verdict β€” these
are leads for a human reviewer (consistent with the recall-first philosophy).
"""
import re
import numpy as np
BENFORD = np.array([np.log10(1 + 1 / d) for d in range(1, 10)])
# numbers like 12, 3.4, 1,234.5, 67% β€” capture the numeric token
_NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?")
# "mean/average ... of N" patterns for GRIM
_MEAN_CTX = re.compile(
r"(?:mean|average|M)\s*(?:=|of|was|is|:)?\s*(\d+\.\d+)", re.I)
_N_CTX = re.compile(r"\b[nN]\s*=\s*(\d{1,5})\b")
def _leading_digit(num_str):
for ch in num_str.replace(",", "").lstrip("0."):
if ch.isdigit() and ch != "0":
return int(ch)
return None
def benford_analysis(text):
digits = []
for m in _NUM.finditer(text):
whole = m.group(1)
# skip years and tiny integers that aren't "data" (1900-2099, 0-9)
clean = whole.replace(",", "")
if len(clean) == 1:
continue
if re.fullmatch(r"(19|20)\d{2}", clean):
continue
d = _leading_digit(whole)
if d:
digits.append(d)
if len(digits) < 30: # too few numbers to judge
return None
digits = np.array(digits)
counts = np.array([(digits == d).sum() for d in range(1, 10)], dtype=float)
observed = counts / counts.sum()
expected = BENFORD
exp_counts = expected * counts.sum()
chi2 = float(((counts - exp_counts) ** 2 / exp_counts).sum())
mad = float(np.mean(np.abs(observed - expected))) # mean abs deviation
# Nigrini's MAD conformity thresholds for first-digit Benford
conformity = ("close" if mad < 0.006 else
"acceptable" if mad < 0.012 else
"marginal" if mad < 0.015 else "nonconformant")
return {
"n_numbers": int(len(digits)),
"observed": [round(float(x), 4) for x in observed],
"expected": [round(float(x), 4) for x in expected],
"chi_square": round(chi2, 2),
"chi2_critical_p05": 15.51, # df=8
"mad": round(mad, 5),
"conformity": conformity,
"suspicious": bool(chi2 > 15.51 and mad >= 0.012),
}
def grim_test(text):
"""Check reported means against GRIM-possible values for their N."""
means = [(m.group(1), m.start()) for m in _MEAN_CTX.finditer(text)]
ns = [(int(m.group(1)), m.start()) for m in _N_CTX.finditer(text)]
if not means or not ns:
return None
flagged = []
for mean_str, mpos in means:
decimals = len(mean_str.split(".")[1])
if decimals == 0 or decimals > 3:
continue
mean_val = float(mean_str)
# nearest reported N (same sentence-ish window)
n = min(ns, key=lambda x: abs(x[1] - mpos))[0]
if not 1 <= n <= 10000:
continue
# GRIM: mean*N should be (close to) an integer
prod = mean_val * n
frac = abs(prod - round(prod))
tol = 0.5 / n + 1e-9 # rounding tolerance for given N
if frac > tol and min(frac, 1 - frac) > tol:
flagged.append({"reported_mean": mean_val, "n": n,
"implied_total": round(prod, 3),
"off_by": round(min(frac, 1 - frac), 4)})
if not flagged:
return None
return {"inconsistent_means": flagged[:10], "count": len(flagged)}
def detect_fraud(text):
"""Returns {benford, grim, flags} or None when there's too little data."""
benford = benford_analysis(text)
grim = grim_test(text)
if benford is None and grim is None:
return None
flags = []
if benford and benford["suspicious"]:
flags.append(f"leading-digit distribution diverges from Benford's law "
f"(MAD {benford['mad']}, chi2 {benford['chi_square']}) β€” "
f"numbers may be fabricated or hand-invented")
if grim:
flags.append(f"{grim['count']} reported mean(s) arithmetically "
f"impossible for the stated sample size (GRIM test)")
return {"benford": benford, "grim": grim, "flags": flags,
"suspicious": bool(flags)}