← Retour à l'épreuve

India INAIO 2026 · Correction IOAI Training

Stage 1 Alternate Test (28 February 2026)

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
points (exact answers)
Évaluée sur
written answers
Notre score
all items solved, checked in Python

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

This is the make-up sitting (28 February 2026) of the first-round online test of the Indian National AI Olympiad 2026. It has the same structure as the main test, with new numbers and twists. Section A1 is puzzle-style reasoning: a toy language, choosing which LLM-extracted features to trust, designing a similarity function, a recursive epidemic, a knight's path. Section A2 contains short modelling puzzles: facility location under two distances, a chess-team match, reading a price model off a graph, constrained optimisation, Bayes. Section B has four case studies: a three-class hospital triage classifier, ranking metrics, 1-D convolution kernels, and pruning a linear classifier. Answers are an option or an integer, usually a floor ⌊⋅⌋\lfloor\cdot\rfloor. Every computable answer was checked by exact or brute-force computation in solution.py.

How you are scored

Each item is worth the points printed next to it (3 points per A1/A2 item, 1 to 4 per case-study question), with no partial credit inside a question. No answer key or qualifying score is published, so the answers below are our own derivations.

Answers at a glance

itemanswer
A1 Imperial glyphsE: 土造蘋果甜
A1 Faulty oracleF: {F1,F3,F6}\{F_1, F_3, F_6\}
A1 Professor searchC
A1 V-67 outbreakD: 102
A1 PikachuE: 15
A2 Godown15
A2 Blind board gambit31
A2 Crude curve49
A2 Catalyst45
A2 Pit-stop15
B1 Triage Q1 to Q743, 90, option 5 (point E), option 2, option 3, claims 3 and 5, 13
B2 WonkaRank Q1 to Q510, option 4, option 3, option 4 (ScoreD only), 30
B3 Convolution Q1 to Q5A; D, F; E, F; E, F; B, D, F
B4 Pruning Q1 to Q336, 13, 65

The key idea

Most items reward the same habits: rebuild the full object (confusion matrix, ranking, parameter set) from the partial information given, look for invariants and closed forms (the minimiser of a sum of Itakura-Saito terms is the mean, antisymmetric kernels vanish on palindromes), and brute-force anything small enough (knight paths, team assignments, pruning pairs) instead of reasoning by hand.

The full solution

Section A1

Imperial glyphs: E. Aligning the sentences gives 蘋果 = apple, 甜 = sweet ("This is a sweet apple" = 這甜蘋果) and 造 = make. "Does the tree make the apple beautiful?" (樹造蘋果美?) and "The mountain paints the palace black" (山塗宮黑) show the order subject, verb, object, resulting adjective. "Soil" never appears in the examples, but every option except one uses either 山 (mountain), a wrong order or an extra 很 ("very"). Only E 土造蘋果甜 has a new subject glyph, 造, 蘋果, 甜 in the right order. The traps: A uses 山 (that is "mountain makes"), B adds 很, C and F put the apple first, D uses 塗 and 宮 (paint the palace).

Faulty oracle: F {F1,F3,F6}\{F_1, F_3, F_6\}. Ask whether a small text-only model with web retrieval can produce each feature reliably.

  • F1F_1 (character count) is broken by tokenisation: the model sees sub-word tokens, not characters (a single len() would do it exactly).
  • F6F_6 (which outlet published this headline) and F3F_3 (that outlet's political leaning) require guessing the source from a headline. That is speculative attribution: errors in F6F_6 propagate into F3F_3, the leaning label is itself subjective, and a source guess is a proxy for the label rather than evidence about the content.
  • With retrieval, F2F_2 (publication year) becomes checkable, F4F_4 (headline/body consistency) is a reading-comprehension task on the given text, and F5F_5, F7F_7, F8F_8 are standard.

The only option made of the three structurally unreliable features is F. (In the main test there was no retrieval, so the year F2F_2 was the unreliable one.)

Professor search: C. Check each rule. A, E and F can exceed 1 (rule 5). B multiplies by NED(d,d′)\mathrm{NED}(d, d'), which is not 0 when departments differ (rule 2). D penalises titles more than names (rule 4). C, 1d=d′exp⁡(−2(1−NED(n,n′))−(1−NED(t,t′)))\mathbf 1_{d = d'}\exp\big(-2(1 - \mathrm{NED}(n, n')) - (1 - \mathrm{NED}(t, t'))\big), is 0 across departments, symmetric (edit distance is), in (0,1](0, 1], equal to 1 exactly when both NED are 1 (identical strings), and the name mismatch has twice the weight.

V-67 outbreak: D 102. Iterating day by day, with Xt=2Nt+(2+4t)MtX_t = 2N_t + (2 + 4t)M_t, Nt+1=Nt/2+3Xt/4N_{t+1} = N_t/2 + 3X_t/4, Mt+1=Mt/2+Xt/4M_{t+1} = M_t/2 + X_t/4 and RR collecting half of Nt+MtN_t + M_t each day: R1=8R_1 = 8, R2=28R_2 = 28, R3=102R_3 = 102.

Pikachu: E 15. An exhaustive depth-first search over knight paths on the 4×44\times 4 board finds a longest path from (0,0)(0,0) to (3,3)(3,3) visiting 15 pads. All 16 is impossible: a knight alternates colours, (0,0)(0,0) and (3,3)(3,3) have the same colour, so a path between them has an odd number of pads.

Section A2

Godown: 15. For Manhattan distance, xx and yy separate, and each minimiser is any median. The xx values sorted are 40,80,120,22040, 80, 120, 220, so the medians form [80,120][80, 120], midpoint 100; for yy also 100. So GS=(100,100)G_S = (100, 100). For Itakura-Saito,

∂∂u∑i(piu−ln⁡piu−1)=∑i(1u−piu2)=0  ⟺  u=pˉ,\frac{\partial}{\partial u}\sum_i \Big(\frac{p_i}{u} - \ln\frac{p_i}{u} - 1\Big) = \sum_i\Big(\frac{1}{u} - \frac{p_i}{u^2}\Big) = 0 \iff u = \bar p,

so GPG_P is the mean, (115,100)(115, 100). Then D=15D = 15 exactly. (A numerical optimiser returns 114.993 and a careless floor of 14: use the closed form.)

Blind board gambit: 31. Enumerating Anand's 6 assignments against the three equally likely Gukesh assignments, the best on average is (Y,X,Z)(Y, X, Z). Against the actual (C,A,B)(C, A, B) the board probabilities for Anand are P(Y beats C)=0.640P(Y \text{ beats } C) = 0.640, P(X beats A)=0.360P(X \text{ beats } A) = 0.360 and P(Z beats B)=0.151P(Z \text{ beats } B) = 0.151. Winning at least 2 of 3 boards gives p=0.3118p = 0.3118, so ⌊100p⌋=31\lfloor 100p\rfloor = 31.

Crude curve: 49. Read the terms off their fingerprints. Before month 18 only A+Bt+Ecos⁡(2πt/τE)A + Bt + E\cos(2\pi t/\tau_E) acts. With τE=12\tau_E = 12 (extremes every 6 months), the points (0,58)(0, 58), (6,45)(6, 45), (12,64)(12, 64) give A=50A = 50, B=0.5B = 0.5, E=8E = 8, and (18−,51)(18^-, 51) checks. The vertical jump at 18 gives D=26−51=−25D = 26 - 51 = -25; (24,45)(24, 45) and (30,32)(30, 32) check. The recovery starts at τC=30\tau_C = 30, and (36,71)(36, 71) gives C=20/(1−e−6)=20.05C = 20/(1 - e^{-6}) = 20.05. The downward trend starts at τF=42\tau_F = 42, and (48,71)(48, 71) gives F=−1.008F = -1.008; (54,52)(54, 52) and (60,65)(60, 65) check (51.95 and 64.90). At t=75t = 75 the cosine is cos⁡(12.5π)=0\cos(12.5\pi) = 0:

P(75)=50+37.5−25+20.05−1.008⋅33=49.28⇒49.P(75) = 50 + 37.5 - 25 + 20.05 - 1.008\cdot 33 = 49.28 \Rightarrow 49 .

Catalyst: 45. Maximise 10cA−2cA2+20cB−3cB210c_A - 2c_A^2 + 20c_B - 3c_B^2 with cA+cB≤5c_A + c_B \le 5. The unconstrained optima (cA=2.5c_A = 2.5, cB=3.33c_B = 3.33) need more than 5 moles, so the constraint binds. Equal marginal yields, 10−4cA=20−6cB10 - 4c_A = 20 - 6c_B with cA+cB=5c_A + c_B = 5, give cA=2c_A = 2, cB=3c_B = 3, and a yield of 12+33=4512 + 33 = 45.

Pit-stop: 15. By Bayes over the 8 (safety car, strategy) cells, with weights P(SC)=0.4P(\mathrm{SC}) = 0.4:

P(neither∣H)=0.6⋅0.5⋅0.2+0.4⋅0.05⋅0.4∑sc,sP(sc) P(s∣sc) P(H∣s,sc)=0.0680.4465=0.152⇒15.P(\text{neither} \mid H) = \frac{0.6\cdot0.5\cdot0.2 + 0.4\cdot0.05\cdot0.4}{\sum_{\mathrm{sc},s} P(\mathrm{sc})\,P(s\mid \mathrm{sc})\,P(H\mid s,\mathrm{sc})} = \frac{0.068}{0.4465} = 0.152 \Rightarrow 15 .

Section B1: triage

Rebuild the confusion matrix (rows predicted, columns actual; class sizes 500, 350, 150). The diagonal is sensitivity times class size: 420, 245, 120. Specificity gives the false positives of each predicted class: (1−speci)(1000−ni)=50(1 - \text{spec}_i)(1000 - n_i) = 50, 7878, 93.593.5. With the two given entries (10 ICU predicted Outpatient, 25 Outpatient predicted ICU) the remaining cells follow, but the published numbers are slightly inconsistent: the column of actual class 2 adds up to 353.5 instead of 350, and the false positives of predicted class 2 come out at 75 instead of 78. We therefore use, for each question, the most direct relation.

  • Q1: 43. Among predicted ICU, the false alarms are FP3=0.11×850=93.5\mathrm{FP}_3 = 0.11\times850 = 93.5, so x=93.5/(120+93.5)=0.438x = 93.5/(120 + 93.5) = 0.438. (Rebuilding the cell from the class-2 column instead gives 42.)
  • Q2: 90. Hospital patients sent home are exactly the false positives of predicted class 1, 0.10×500=500.10 \times 500 = 50, so x=450/500=0.90x = 450/500 = 0.90.
  • Q3: point E (option 5). The expected cost per patient is 0.15⋅8 (1−TPR)+0.85 FPR0.15\cdot 8\,(1 - \mathrm{TPR}) + 0.85\,\mathrm{FPR}: A 0.593, B 0.427, C 0.356, D 0.334, E 0.287, F 0.295. The optimum is where the ROC slope equals 0.85⋅10.15⋅8=0.71\frac{0.85\cdot 1}{0.15\cdot 8} = 0.71.
  • Q4: option 2, the curve improves. The biomarker carries information independent of solds_{\text{old}} given the class, so adding it with a positive weight separates the classes better. In a simulation (Gaussian scores, AUC 0.87) the new curve lies above the old one at every FPR and the AUC rises to 0.95. Strictly, a fixed δ\delta is not the optimal (likelihood-ratio) weight, and with an already near-perfect score the curves can touch; but among the options "improves consistently" is the intended answer.
  • Q5: option 3. A ROC curve is invariant to prevalence (1), to the threshold (2: moving along the curve), to monotone transforms (4) and to removing a random subset of a class (6), and sampling noise (5) does not produce a consistent shift. Only a better score, from new predictive features, moves the whole curve up and left.
  • Q6: claims 3 and 5. Claim 1 is false: the cascade Y always outputs one label (only the pairwise scheme Z can be inconsistent and need voting). Claim 2 is false: "strictly greater on any dataset" never holds. Claim 3 is true: a single scorer with thresholds is ordinal regression, which squared loss on ordered targets enforces naturally, and squared loss is outlier-sensitive. Claim 4 is false: it is the opposite. Claim 5 is true: the log loss is a proper scoring rule, while the hinge loss only produces margins.
  • Q7: 13. The cost is (55⋅10+25⋅100+40⋅50+68.5⋅20+10⋅500+20⋅100)/1000=13.42\big(55\cdot10 + 25\cdot100 + 40\cdot50 + 68.5\cdot20 + 10\cdot500 + 20\cdot100\big)/1000 = 13.42. The answer is 13 whichever consistent completion of the matrix is used (13.25 to 13.42).

Section B2: WonkaRank

  • Q1: 10. DCG@5=3log⁡22+0+10log⁡24+2log⁡25+5log⁡26=3+5+0.861+1.934=10.80\mathrm{DCG}@5 = \frac{3}{\log_2 2} + 0 + \frac{10}{\log_2 4} + \frac{2}{\log_2 5} + \frac{5}{\log_2 6} = 3 + 5 + 0.861 + 1.934 = 10.80.
  • Q2: option 4. The top 8 of Strategy A contains 3 of the 4 relevant chocolates (precision 0.375, recall 0.75). Strategy B gives score 1 to D9 (it has hazelnut), which enters the top 8 instead of D6: precision 0.5, recall 1. Both improve.
  • Q3: option 3. DCG is maximised by sorting by relevance, [D1,D8,D5,D9][D_1, D_8, D_5, D_9] (relevances 10, 5, 3, 2): 15.52, against 15.45 for option 5 and 15.25 for option 1.
  • Q4: option 4, ScoreD only. The top 3 are: ScoreA [D2,D5,D1][D_2, D_5, D_1] (2/3), ScoreB [D2,D3,D4][D_2, D_3, D_4] (0), ScoreC [D1,D2,D5][D_1, D_2, D_5] (2/3), ScoreD [D1,D8,D5][D_1, D_8, D_5] (3/3). Dividing by 1+∑di1 + \sum d_i penalises chocolates that contain everything (the ticketless D2).
  • Q5: 30. The ScoreA ranking is [D2,D5,D1,D8,D3][D_2, D_5, D_1, D_8, D_3]; relevant items sit at positions 2, 3, 4, so PI-Recall@5=(0.6+0.4+0.2)/4=0.30\text{PI-Recall}@5 = (0.6 + 0.4 + 0.2)/4 = 0.30.

Section B3: convolution

With yi=∑jhjxi+k−jy_i = \sum_j h_j x_{i+k-j} (kernel reversed):

  • Q1: A. The padded series is [2,1,3,2,4,2][2, 1, 3, 2, 4, 2]; with stride 2 the windows (2,1)(2, 1), (3,2)(3, 2), (4,2)(4, 2) give [4,7,8][4, 7, 8].
  • Q2: D and F. A second-difference kernel (∑h=0\sum h = 0 and zero on linear ramps) gives 0 on constants and on the smooth ramp, and large values on zig-zags: [1,−2,1][1, -2, 1] gives S=−10S = -10, and [−2,4,−2][-2, 4, -2] gives 2020 on zz. A gives 12 on the smooth signal, B fails on constants, C and E are first-difference-like.
  • Q3: E and F. SS must be 0 for constant velocity, negative when decelerating and positive when accelerating, i.e. a positive multiple of [1,−2,1][1, -2, 1]: EE (−3-3 and +3+3) and FF (−9-9 and +9+9). B has the opposite sign.
  • Q4: E and F. A kernel that vanishes on every linear signal (the second difference, up to sign) gives Sabs=0S_{\text{abs}} = 0 on constants and on the ramp zz, and 24 on the spike. All others are non-zero on zz.
  • Q5: B, D and F. The single output is y=h1x4+h2x3+h3x2+h4x1y = h_1x_4 + h_2x_3 + h_3x_2 + h_4x_1. It vanishes on every palindrome (x1=x4x_1 = x_4, x2=x3x_2 = x_3) exactly when hh is antisymmetric (h4=−h1h_4 = -h_1, h3=−h2h_3 = -h_2): [1,−1,1,−1][1,-1,1,-1], [2,1,−1,−2][2,1,-1,-2] and [1,0,0,−1][1,0,0,-1]. (No single linear output can be non-zero on all asymmetric signals, since its null space has dimension 3, but these three are the kernels that pass the stated test.)

Section B4: pruning

  • Q1: 36. Option A forces w=(2,−3,−1,−1)w = (2, -3, -1, -1), so s6=w1+w3+w4=0s_6 = w_1 + w_3 + w_4 = 0: predicted 0 for a positive example, accuracy 5/6. Option B forces w=(3,−5,−1,−1)w = (3, -5, -1, -1) with all six signs correct, accuracy 1. So ∥w∗∥2=9+25+1+1=36\lVert w^\ast\rVert^2 = 9 + 25 + 1 + 1 = 36.
  • Q2: 13. Trying all 21 pairs, the unique best (accuracy 6/7) is {6,7}\{6, 7\}, so j∗+k∗=13j^\ast + k^\ast = 13.
  • Q3: 65. After pruning, the scores are (1,1,1,1,1,0,−5)(1, 1, 1, 1, 1, 0, -5). Every b∈[1,5]b \in [1, 5] gives accuracy 7/7, and the tie-break picks the largest, b∗=5b^\ast = 5, so 13×5=6513\times5 = 65.

Results

All answers are in the table at the top. Every numeric item (V-67, Pikachu, Godown, gambit, crude curve, catalyst, pit-stop, all of B1 Q1 to Q3 and Q7, B2, B3, B4) was recomputed in solution.py. Two items depend on how a slightly inconsistent statement is read: B1 Q1 (43 with the specificity of class 3, 42 if the cell is rebuilt from the class-2 column) and B1 Q4 (option 2 intended; option 6 is the pedantic alternative).

What to remember

  • Closed forms beat optimisers for floors. The Itakura-Saito minimiser is exactly the mean; a numerical optimiser stopping at 114.993 would have turned 15 into 14.
  • Rebuild the confusion matrix and check it. Sensitivities, specificities and class sizes overdetermine it; checking the redundant equations reveals inconsistencies in the data before they silently change your answer.
  • ROC curves depend only on the ranking within each class. Prevalence, thresholds, monotone transforms and random subsampling never move them; better features do.
  • Kernels are finite differences. First difference: velocity; second difference: acceleration and spikes; antisymmetric kernels vanish on palindromes. Check the null space before checking the options.
  • Brute force small search spaces. Knight paths, team assignments and pruning pairs are enumerable in milliseconds, and parity arguments (the knight's colour) explain the answer afterwards.
Code complet (solution.py)
"""INAIO 2026 Stage 1 Alternate Test (28 Feb 2026): brute-force / exact checks of every computable item."""
import itertools, math
import numpy as np
from scipy.optimize import minimize

# ---------- A1 V-67 outbreak ----------
N, M, R = 8.0, 8.0, 0.0
for t in range(3):
    X = 2 * N + (2 + 4 * t) * M
    R += 0.5 * N + 0.5 * M
    N, M = 0.5 * N + 0.75 * X, 0.5 * M + 0.25 * X
print("A1 V-67   R3 =", R)

# ---------- A1 Pikachu: longest knight path (0,0) -> (3,3) on 4x4 ----------
moves = [(1, 2), (2, 1), (-1, 2), (-2, 1), (1, -2), (2, -1), (-1, -2), (-2, -1)]
best = 0
def dfs(p, seen):
    global best
    if p == (3, 3):
        best = max(best, len(seen)); return
    for dx, dy in moves:
        q = (p[0] + dx, p[1] + dy)
        if 0 <= q[0] < 4 and 0 <= q[1] < 4 and q not in seen:
            seen.add(q); dfs(q, seen); seen.remove(q)
dfs((0, 0), {(0, 0)})
print("A1 Pikachu max pads =", best)

# ---------- A2 Godown ----------
xs, ys = np.array([40, 120, 220, 80.]), np.array([180, 80, 120, 20.])
GS = np.array([np.mean(np.sort(xs)[1:3]), np.mean(np.sort(ys)[1:3])])     # L1: median interval midpoint
IS = lambda p, u: p / u - np.log(p / u) - 1
GPnum = minimize(lambda z: IS(xs, z[0]).sum() + IS(ys, z[1]).sum(), [100, 100]).x
GP = np.array([xs.mean(), ys.mean()])     # d/du sum IS(p_i, u) = sum(1/u - p_i/u^2) = 0  ->  u = mean(p)
print("A2 Godown GS", GS, "GP exact", GP, "(numerical", GPnum.round(3), ") floor D =", math.floor(np.hypot(*(GS - GP)) + 1e-9))

# ---------- A2 Blind board gambit ----------
G = dict(A=2100, B=1800, C=1600); An = dict(X=2000, Y=1700, Z=1500)
pw = lambda r, s: 1 / (1 + 10 ** ((s - r) / 400))
def p_anand(gk, an):
    ps = [pw(An[a], G[g]) for g, a in zip(gk, an)]
    return sum(np.prod([ps[i] if w else 1 - ps[i] for i, w in enumerate(res)])
               for res in itertools.product([0, 1], repeat=3) if sum(res) >= 2)
cands = [("B", "C", "A"), ("C", "A", "B"), ("C", "B", "A")]
choice = max(itertools.permutations("XYZ"), key=lambda an: np.mean([p_anand(g, an) for g in cands]))
p = p_anand(("C", "A", "B"), choice)
print("A2 Gambit Anand picks", choice, "p =", round(p, 4), "floor 100p =", math.floor(100 * p))

# ---------- A2 Crude curve (parameters read off the graph) ----------
A_, B_, E_, tauE = 50, 0.5, 8, 12
D_ = 26 - 51
C_ = (71 - (A_ + B_ * 36 + E_ * math.cos(2 * math.pi * 36 / tauE) + D_)) / (1 - math.exp(-6))
F_ = (71 - (A_ + B_ * 48 + E_ * math.cos(2 * math.pi * 48 / tauE) + D_ + C_ * (1 - math.exp(-18)))) / 6
P = lambda t: (A_ + B_ * t + C_ * (1 - math.exp(-(t - 30))) * (t >= 30) + D_ * (t >= 18)
               + F_ * (t - 42) * (t >= 42) + E_ * math.cos(2 * math.pi * t / tauE))
print("A2 Crude  C=%.3f F=%.3f  check P(54)=%.2f P(60)=%.2f  P(75)=%.3f" % (C_, F_, P(54), P(60), P(75)))

# ---------- A2 Catalyst ----------
cA = np.linspace(0, 5, 500001); y = 10 * cA - 2 * cA**2 + 20 * (5 - cA) - 3 * (5 - cA) ** 2
print("A2 Catalyst max yield =", round(y.max(), 4), "at cA =", round(cA[y.argmax()], 3), "(unconstrained optimum needs 2.5 + 3.33 > 5 moles)")

# ---------- A2 Pit stop ----------
pS = {False: [0.50, 0.25, 0.10, 0.15], True: [0.05, 0.10, 0.05, 0.80]}
pH = {False: [0.20, 0.85, 0.10, 0.50], True: [0.40, 0.15, 0.90, 0.55]}
joint = {(sc, k): (0.4 if sc else 0.6) * pS[sc][k] * pH[sc][k] for sc in (False, True) for k in range(4)}
pn = (joint[(False, 0)] + joint[(True, 0)]) / sum(joint.values())
print("A2 Pit-stop P(neither | H wins) = %.4f -> %d" % (pn, math.floor(100 * pn)))

# ---------- B1 Triage ----------
n = np.array([500, 350, 150]); sens = np.array([.84, .70, .80]); spec = np.array([.90, .88, .89])
tp = sens * n; fp = (1 - spec) * (1000 - n)            # predicted i but actual not i
# unknown confusion entries C[pred][actual]; C[0][2]=10, C[2][0]=25 given
C = np.zeros((3, 3)); np.fill_diagonal(C, tp); C[0, 2] = 10; C[2, 0] = 25
C[1, 0] = n[0] - tp[0] - C[2, 0]; C[1, 2] = n[2] - tp[2] - C[0, 2]
C[0, 1] = fp[0] - C[0, 2]; C[2, 1] = fp[2] - C[2, 0]
print("B1 confusion (rows=predicted, cols=actual):\n", C, "\n   column sums", C.sum(0), "row-check fp", C[1, 0] + C[1, 2], "vs", fp[1])
print("B1 Q1  false alarms among predicted ICU: %.4f -> %d" % ((C[2].sum() - C[2, 2]) / C[2].sum(), math.floor(100 * (C[2].sum() - C[2, 2]) / C[2].sum())))
hosp = (n[1:].sum() - fp[0]) / n[1:].sum()      # predicted C1 among actual non-C1 = FP of class 1; print("B1 Q2  hospitalised correctly flagged: %.4f -> %d" % (hosp, math.floor(100 * hosp)))
pts = dict(A=(.02, .52), B=(.05, .68), C=(.08, .76), D=(.11, .80), E=(.14, .86), F=(.22, .91))
cost = {k: 0.15 * 8 * (1 - t) + 0.85 * 1 * f for k, (f, t) in pts.items()}
print("B1 Q3  expected cost per point", {k: round(v, 4) for k, v in cost.items()}, "best", min(cost, key=cost.get))
Cost = np.array([[0, 10, 100], [50, 0, 20], [500, 100, 0]])   # [actual][pred]
x = sum(Cost[a, p_] * C[p_, a] for a in range(3) for p_ in range(3)) / 1000
print("B1 Q7  expected cost per patient = %.3f -> %d" % (x, math.floor(x)))
rng = np.random.default_rng(0)                          # Q4: simulate the RDT augmentation
yb = rng.random(200000) < .15; s_old = rng.normal(0, 1, yb.size) + 1.6 * yb
b = np.where(yb, rng.random(yb.size) < .85, rng.random(yb.size) > .88)
from sklearn.metrics import roc_auc_score
from sklearn.metrics import roc_curve
print("B1 Q4  AUC old %.3f  new %.3f" % (roc_auc_score(yb, s_old), roc_auc_score(yb, s_old + 1.5 * (2 * b - 1))))
f0, t0, _ = roc_curve(yb, s_old); f1, t1, _ = roc_curve(yb, s_old + 1.5 * (2 * b - 1)); g = np.linspace(.005, .995, 199)
for sep in (0.5, 1.6, 3.0):
    so = rng.normal(0, 1, yb.size) + sep * yb; f0, t0, _ = roc_curve(yb, so); f1, t1, _ = roc_curve(yb, so + 1.5 * (2 * b - 1))
    print(f"B1 Q4  class separation {sep}: new ROC above old at {np.mean(np.interp(g, f1, t1) >= np.interp(g, f0, t0) - 1e-3):.0%} of FPR grid")

# ---------- B2 WonkaRank ----------
D = {1: (1, 1, 0, 0, 10), 2: (1, 1, 1, 1, 0), 3: (1, 0, 1, 1, 0), 4: (0, 1, 1, 1, 0), 5: (1, 1, 0, 1, 3),
     6: (0, 0, 1, 1, 0), 7: (1, 0, 0, 0, 0), 8: (1, 1, 0, 0, 5), 9: (0, 1, 1, 0, 2), 10: (0, 1, 0, 0, 0)}
rel = {k: v[4] for k, v in D.items()}; Rtot = sum(r > 0 for r in rel.values())
dcg = lambda order: sum(rel[d] / math.log2(i + 2) for i, d in enumerate(order))
print("B2 Q1  DCG@5 =", round(dcg([5, 3, 1, 9, 8]), 4))
rank = lambda f: sorted(D, key=lambda k: (-f(D[k]), k))
sA = lambda v: 2 * (v[0] and v[1]); sB = lambda v: 2 if (v[0] and v[1]) else (1 if (v[0] or v[1]) else 0)
for name, f in (("A", sA), ("B", sB)):
    top = rank(f)[:8]; hit = sum(rel[d] > 0 for d in top)
    print(f"B2 Q2  strategy {name} top8 {top} precision {hit / 8:.3f} recall {hit / Rtot:.3f}")
print("B2 Q3  ", {str(o): round(dcg(o), 3) for o in ([1, 5, 8, 9], [8, 1, 5, 9], [1, 8, 5, 9], [5, 9, 1, 8], [1, 8, 9, 5], [9, 5, 8, 1])})
W = (3, 3, 1, 1)
scores = {"A": lambda v: sum(d * w for d, w in zip(v[:4], W)), "B": lambda v: sum(min(1, d) * min(1, w) for d, w in zip(v[:4], W)),
          "C": lambda v: sum(d * w for d, w in zip(v[:4], W)) - sum(v[:4]),
          "D": lambda v: sum(d * w for d, w in zip(v[:4], W)) / (1 + sum(v[:4]))}
for k, f in scores.items():
    top = rank(f)[:3]; print(f"B2 Q4  score {k}: top3 {top} precision@3 {sum(rel[d] > 0 for d in top) / 3:.3f}")
pi = [.8, .6, .4, .2, .1]; top5 = rank(scores["A"])[:5]
pir = sum(p_ * (rel[d] > 0) for p_, d in zip(pi, top5)) / Rtot
print("B2 Q5  ranking", top5, "PI-recall@5 = %.4f -> %d" % (pir, math.floor(100 * pir + 1e-9)))

# ---------- B3 Convolution ----------
def conv(x, h, stride=1):
    k = len(h); return [sum(h[j] * x[i + k - 1 - j] for j in range(k)) for i in range(0, len(x) - k + 1, stride)]
print("B3 Q1  ", conv([2, 1, 3, 2, 4, 2], [2, 1], 2))
x2, z2 = [2, 4, 6, 8, 10], [2, 6, 3, 10, 4]
for name, h in zip("ABCDEF", ([1, 0, -1], [2, -3, 2], [1, 1, -2], [1, -2, 1], [0, -1, 1], [-2, 4, -2])):
    print(f"B3 Q2  {name} {h}: const S={sum(conv([5] * 5, h))} smooth S={sum(conv(x2, h))} fluct S={sum(conv(z2, h))}")
x3, w3, lin = [1, 5, 8, 10, 11], [1, 2, 4, 7, 11], [3, 5, 7, 9, 11]
for name, h in zip("ABCDEF", ([-1, 0, 1], [-1, 2, -1], [1, -1, 0], [0, 1, -2], [1, -2, 1], [3, -6, 3])):
    print(f"B3 Q3  {name} {h}: const-vel S={sum(conv(lin, h))} decel S={sum(conv(x3, h))} accel S={sum(conv(w3, h))}")
x4, z4 = [1, 2, 8, 2, 1, 2], [1, 3, 5, 7, 9, 11]
for name, h in zip("ABCDEF", ([1, 1, -2], [-1, 0, 1], [2, -3, 1], [1, -1, 0], [-1, 2, -1], [1, -2, 1])):
    sab = lambda s: sum(abs(v) for v in conv(s, h))
    print(f"B3 Q4  {name} {h}: const {sab([4] * 6)} smooth {sab(z4)} spike {sab(x4)}")
rs = np.random.default_rng(1)
for name, h in zip("ABCDEF", ([1, 1, 1, -1], [1, -1, 1, -1], [-1, 2, 1, 1], [2, 1, -1, -2], [1, -1, -1, 0], [1, 0, 0, -1])):
    sym = all(abs(conv([a, b_, b_, a], h)[0]) < 1e-12 for a, b_ in rs.integers(-9, 9, (200, 2)))
    asym = np.mean([abs(conv(list(v), h)[0]) > 0 for v in rs.integers(-9, 9, (2000, 4)) if v[0] != v[3] or v[1] != v[2]])
    print(f"B3 Q5  {name} {h}: zero on all symmetric: {sym}; non-zero on {asym:.0%} of asymmetric")

# ---------- B4 Pruning ----------
X1 = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1], [1, 1, 0, 0], [1, 0, 1, 1]]); y1 = np.array([1, 0, 0, 0, 0, 1])
wA = np.array([2, -3, -1, -1])            # s1=w1=2, s3=w3=-1, s4=w4=-1, s5=w1+w2=-1 -> w2=-3
wB = np.array([3, -5, -1, -1])            # s1=3, s2=-5, s4=-1, s6=w1+w3+w4=1 -> w3=-1
for name, w in (("A", wA), ("B", wB)):
    s = X1 @ w; acc = np.mean((s > 0) == y1)
    print(f"B4 Q1  option {name}: w={w} s={s} accuracy {acc:.3f} |w|^2={np.sum(w**2)}")
X2 = np.array([[-1, 0, 0, 0, 0, 0, 3], [0, -1, 0, 0, 0, 0, 0], [0, 0, -1, 0, 0, 0, -2], [0, 0, 0, -1, 0, 0, 6],
               [0, 0, 0, 0, -1, 0, 7], [0, 0, 0, 0, 0, 1, -6], [1, 1, 1, 1, 1, 5, -9]])
w2 = np.array([-1, -1, -1, -1, -1, 1, 0]); y2 = np.array([1, 1, 1, 1, 1, 1, 0])
accs = {}
for j, k in itertools.combinations(range(7), 2):
    w = w2.copy(); w[[j, k]] = 0; accs[(j + 1, k + 1)] = np.mean(((X2 @ w) > 0) == y2)
bestacc = max(accs.values()); bests = [p_ for p_, a in accs.items() if a == bestacc]
print("B4 Q2  best accuracy %.3f with pairs %s (unpruned %.3f)" % (bestacc, bests, np.mean(((X2 @ w2) > 0) == y2)))
for (j, k) in bests:
    w = w2.copy(); w[[j - 1, k - 1]] = 0; s = X2 @ w
    accb = {b_: np.mean(((s + b_) > 0) == y2) for b_ in range(-5, 6)}
    m = max(accb.values()); bstar = max(b_ for b_, a in accb.items() if a == m)
    print(f"B4 Q3  pair {(j, k)}: scores {s}, best acc {m:.3f}, b* = {bstar}, answer {(j + k) * abs(bstar)}")