"""
label.py — numerical companion to the glossary entry 'label'.

One block per paragraph of the entry (marked [P...]): each block verifies
numerically what the corresponding statement asserts. Self-contained
(numpy/matplotlib only), fixed seed.

Blocks
------
[P-choice]   Feature vs label is a design choice: with the "oncologist
             available" design the prognosis enters the feature vector
             and improves prediction of the target; without it the
             prognosis is the label to be predicted from the remaining
             features.
[P-labelfun] The labeling function h-bar acts on the data point itself:
             a deterministic function assigns each (fully observed)
             synthetic patient its label.
[P-partial]  The feature vector captures only part of the data point:
             two data points with identical features carry different
             labels, so no hypothesis reading only the features predicts
             perfectly — the empirical error of the best such hypothesis
             stays bounded away from zero, while a hypothesis with
             access to the full data point achieves zero error.
[P-noise]    The observed label is a noisy proxy: training on labels
             flipped with increasing probability degrades the test error
             of the learned hypothesis (label noise degrades training
             and generalization).

Outputs
-------
label.png : preview figure (checking only).

Data generated by pythondemos/label.py.
"""

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
report = []


def check(name, ok):
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")


# synthetic patients: (weight, age, hidden_marker) fully describe the
# data point; the label depends on all three.
m = 600
weight = 60 + 20 * rng.random(m)
age = 20 + 50 * rng.random(m)
hidden = rng.normal(size=m)                       # never recorded as feature
z = np.stack([weight, age, hidden], axis=1)       # the data point itself
label_fun = lambda Z: (0.03 * Z[:, 0] + 0.04 * Z[:, 1]
                       + 1.5 * Z[:, 2] > 4.0)     # labeling function h-bar
y = label_fun(z)

# --------------------------------------------------------- [P-choice]
print("[P-choice] feature vs label as a design choice")
prognosis = (0.5 * hidden + 0.2 * rng.normal(size=m) > 0)  # expert output
target = y
def acc(X, yy):
    Xb = np.c_[X, np.ones(m)]
    w = np.linalg.lstsq(Xb, yy.astype(float), rcond=None)[0]
    return np.mean((Xb @ w > 0.5) == yy)
acc_without = acc(np.c_[weight, age], target)
acc_with = acc(np.c_[weight, age, prognosis.astype(float)], target)
print(f"    accuracy without / with the prognosis feature: "
      f"{acc_without:.2f} / {acc_with:.2f}")
check("prognosis used as a FEATURE improves the prediction",
      acc_with > acc_without + 0.05)
check("prognosis used as the LABEL is predictable from features "
      "(better than chance)",
      acc(np.c_[weight, age], prognosis) >= 0.5)

# ------------------------------------------------------- [P-labelfun]
print("[P-labelfun] the labeling function acts on the data point")
check("h-bar is deterministic on data points",
      np.array_equal(label_fun(z), y))
check("h-bar reads the whole data point (hidden part matters)",
      not np.array_equal(label_fun(z),
                         label_fun(np.c_[z[:, :2], np.zeros(m)])))

# -------------------------------------------------------- [P-partial]
print("[P-partial] identical features, different labels")
x_feat = np.round(np.stack([weight, age], axis=1))  # recorded features
# find twin data points with equal features but different labels
_, inv, counts = np.unique(x_feat, axis=0, return_inverse=True,
                           return_counts=True)
twin_exists = any(len(set(y[inv == g])) > 1
                  for g in np.nonzero(counts > 1)[0])
check("two data points share features but differ in the label",
      twin_exists)
err_feat = 1 - acc(x_feat, y)
Xfull = np.c_[z, np.ones(m)]
w_full = np.linalg.lstsq(Xfull, y.astype(float), rcond=None)[0]
err_full = np.mean((Xfull @ w_full > 0.5) != y)
print(f"    error with features only: {err_feat:.2f}; "
      f"with the full data point: {err_full:.2f}")
check("features-only hypothesis cannot be perfect", err_feat > 0.05)
check("full-data-point hypothesis is (nearly) perfect", err_full < 0.02)

# ---------------------------------------------------------- [P-noise]
print("[P-noise] label noise degrades training and generalization")
Xtr, Xte = x_feat[:400], x_feat[400:]
ytr0, yte = y[:400], y[400:]
errs = []
for flip in (0.0, 0.2, 0.4):
    errs_r = []
    for _ in range(25):
        ytr = ytr0.copy()
        idx = rng.choice(400, int(flip * 400), replace=False)
        ytr[idx] = ~ytr[idx]
        Xb = np.c_[Xtr, np.ones(400)]
        w = np.linalg.lstsq(Xb, ytr.astype(float), rcond=None)[0]
        errs_r.append(np.mean((np.c_[Xte, np.ones(200)] @ w > 0.5)
                              != yte))
    errs.append(float(np.mean(errs_r)))
print(f"    test error at flip = 0, 0.2, 0.4: "
      f"{errs[0]:.2f}, {errs[1]:.2f}, {errs[2]:.2f}")
check("test error grows with the label-noise level",
      errs[0] < errs[1] < errs[2])

# ------------------------------------------------------------ preview
fig, ax = plt.subplots(figsize=(4.6, 3.2))
ax.plot([0, 0.2, 0.4], errs, "o-")
ax.set_xlabel("label-flip probability"); ax.set_ylabel("test error")
ax.set_title("[P-noise] label noise degrades generalization")
fig.tight_layout()
fig.savefig("label.png", dpi=110)
print(f"\n{sum(ok for _, ok in report)}/{len(report)} checks passed")
assert all(ok for _, ok in report)
