<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gpu on Wilson Wu</title><link>https://wilsonwu.me/tags/gpu/</link><description>Recent content in Gpu on Wilson Wu</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Wed, 14 Oct 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/tags/gpu/index.xml" rel="self" type="application/rss+xml"/><item><title>Train Your Own Jev: An End-to-End Guide to Decision Models, from Principles and Data to Compute, Training, and Evaluation</title><link>https://wilsonwu.me/blog/2026/train-your-own-jev/</link><pubDate>Wed, 14 Oct 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/blog/2026/train-your-own-jev/</guid><description>&lt;p&gt;&lt;img alt="Train Your Own Jev" loading="lazy" src="https://wilsonwu.me/blog/2026/train-your-own-jev/1-train-your-own-jev.png"&gt;&lt;/p&gt;
&lt;h2 id="introduction"&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Over the past month I&amp;rsquo;ve written two articles on decision models: &lt;a href="https://wilsonwu.me/blog/2026/jev-vs-laya/"&gt;Jev vs Laya&lt;/a&gt; compared the closed-source Jev with the open-source Laya, and &lt;a href="https://wilsonwu.me/blog/2026/microsoft-decision-1/"&gt;Microsoft-Decision-1 Explained&lt;/a&gt; added Microsoft to the picture. The question I&amp;rsquo;ve been asked most since then is: &lt;strong&gt;can I train one myself?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes, you can, and the bar is lower than you might think. Just look at the third-party leaderboard JevBench: as of mid-October, its open-weights board ranks more than 160 systems, the vast majority built by the community and small teams. The ones at the top aren&amp;rsquo;t mysterious either: H2O-Lightning-4B, first on the composite score, is a fine-tune of Alibaba&amp;rsquo;s open-source Qwen3.5-4B; Quyet-1.0-Large, first on the capability score, is Gemma-4-31B plus an adapter; and Microsoft&amp;rsquo;s own Microsoft-Decision-1 is also built on the open-source Qwen3.5-9B.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="Train Your Own Jev" loading="lazy" src="/blog/2026/train-your-own-jev/1-train-your-own-jev.png"></p>
<h2 id="introduction">Introduction</h2>
<p>Over the past month I&rsquo;ve written two articles on decision models: <a href="/blog/2026/jev-vs-laya/">Jev vs Laya</a> compared the closed-source Jev with the open-source Laya, and <a href="/blog/2026/microsoft-decision-1/">Microsoft-Decision-1 Explained</a> added Microsoft to the picture. The question I&rsquo;ve been asked most since then is: <strong>can I train one myself?</strong></p>
<p>Yes, you can, and the bar is lower than you might think. Just look at the third-party leaderboard JevBench: as of mid-October, its open-weights board ranks more than 160 systems, the vast majority built by the community and small teams. The ones at the top aren&rsquo;t mysterious either: H2O-Lightning-4B, first on the composite score, is a fine-tune of Alibaba&rsquo;s open-source Qwen3.5-4B; Quyet-1.0-Large, first on the capability score, is Gemma-4-31B plus an adapter; and Microsoft&rsquo;s own Microsoft-Decision-1 is also built on the open-source Qwen3.5-9B.</p>
<p>That tells us something: <strong>the competitive edge of a decision model lies not in its architecture but in its data, its training objective, and its evaluation system.</strong> All three are within reach of an ordinary team.</p>
<p>This article walks through the whole process of training &ldquo;your own Jev&rdquo; in the order of a real project:</p>
<ol>
<li>Principles: what a decision model actually learns, and why the training objective must be a &ldquo;strictly proper scoring rule&rdquo;</li>
<li>Data: where to find it, and how to unify, clean, augment, and split it</li>
<li>Compute: how much GPU memory, how many GPU hours, and how much money</li>
<li>Base model: how to choose one</li>
<li>Training: writing a ready-to-run LoRA training script from scratch</li>
<li>Evaluation: which metrics to use, and how to compare with the models on JevBench</li>
<li>Tuning: what to try when the scores disappoint</li>
</ol>
<p>I actually ran every script in this article on Qwen3.5-0.8B (CPU smoke tests, only to verify that the code is correct). All leaderboard and model figures come from public sources, linked at the end.</p>
<h2 id="part-1-what-does-a-decision-model-actually-learn">Part 1: What Does a Decision Model Actually Learn?</h2>
<h3 id="1-at-its-core-a-conditional-probability">1. At Its Core, a Conditional Probability</h3>
<p>First, a quick recap of the decision-model interface: the caller provides a piece of state and a question whose candidate answers are defined in advance, and the model returns a probability for each candidate. All three question primitives boil down to the same thing:</p>
<table>
	<thead>
			<tr>
					<th>Primitive</th>
					<th>Candidate answers</th>
					<th>What it really is</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>choice</code></td>
					<td>K options defined by the caller</td>
					<td>K-way classification</td>
			</tr>
			<tr>
					<td><code>noul</code></td>
					<td>Yes / no</td>
					<td>Binary classification</td>
			</tr>
			<tr>
					<td><code>score</code></td>
					<td>K ordered levels</td>
					<td>Ordinal K-way classification</td>
			</tr>
	</tbody>
</table>
<p>So training a decision model means teaching it to estimate <code>P(option | state, question)</code>. Compared with an ordinary text classifier, there are two key differences:</p>
<ul>
<li><strong>The classes are not fixed</strong>: every request comes with different questions and options, so the model has to understand what the options mean instead of memorizing a fixed label set. That&rsquo;s why it needs zero-shot generalization.</li>
<li><strong>The probability is the product</strong>: downstream code branches directly on the probabilities, so they must be &ldquo;honest&rdquo;, that is, calibrated. This drives the choice of training objective, covered in detail below.</li>
</ul>
<h3 id="2-three-technical-approaches">2. Three Technical Approaches</h3>
<p>Community projects roughly fall into three camps:</p>
<table>
	<thead>
			<tr>
					<th>Approach</th>
					<th>Examples</th>
					<th>How it works</th>
					<th>Pros</th>
					<th>Cons</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Encoder + decision head</td>
					<td>Laya</td>
					<td>Puts a <code>[MASK]</code> in front of every option, runs a bidirectional encoder, and scores the hidden states at those positions</td>
					<td>Extremely fast: 400M parameters, 33–40 ms per question on a T4</td>
					<td>Weak zero-shot ability, short context, must be fine-tuned for each task</td>
			</tr>
			<tr>
					<td>Decoder + option letters</td>
					<td>Quyet-1.0-Large, H2O-Lightning-4B</td>
					<td>Labels the options A, B, C, …; after reading the prompt, the model takes the next-token probabilities of those letters</td>
					<td>Reuses the LLM&rsquo;s knowledge and its language-model head; strong zero-shot ability</td>
					<td>Large; the number of options is limited by how many single-token labels exist</td>
			</tr>
			<tr>
					<td>Decoder + decision head</td>
					<td>jiwo, ClassOne</td>
					<td>Feeds the decoder&rsquo;s hidden states into a separately trained scoring head</td>
					<td>More flexible in option count and output format</td>
					<td>The head is trained from scratch, so it depends more on data</td>
			</tr>
	</tbody>
</table>
<p>The JevBench data sends a clear signal:</p>
<ul>
<li>The encoder-based systems all score in the single digits on zero-shot intelligence (Laya 1.8, modernbert-jev v2 2.6, open-jev-deberta-v3-large 2.8), barely better than random guessing;</li>
<li>Reading the logits of Qwen3-4B-Instruct-2507 directly, without any decision fine-tuning, already scores 34.0;</li>
<li>H2O-Lightning-4B, decision-tuned on Qwen3.5-4B, reaches 60.0, close to Jev 1.13.0&rsquo;s 63.6;</li>
<li>The larger Gemma-4-31B base (Quyet-1.0-Large) reaches 73.4.</li>
</ul>
<p>The conclusion is clear: <strong>if you want a &ldquo;zero-shot generalist&rdquo; like Jev, take the decoder route.</strong> The knowledge an LLM picks up in pre-training is the source of its zero-shot ability, and decision fine-tuning &ldquo;aligns&rdquo; that knowledge with option probabilities. If you only serve one or two fixed tasks, have extreme latency requirements, and have plenty of labeled data, an encoder like Laya is still the most cost-effective choice.</p>
<p>This article follows the second approach: <strong>decoder + option letters</strong>. Inference works like this:</p>
<p><img alt="Reading option probabilities in a single forward pass" loading="lazy" src="/blog/2026/train-your-own-jev/2-letter-logit-readout.png"></p>
<ol>
<li>Combine the state, the question, and the letter-labeled options into a chat prompt that ends where the assistant&rsquo;s reply begins;</li>
<li>Run one forward pass through the model (prefill only, no token-by-token generation);</li>
<li>At the last position (the answer slot), take the logits of the letter tokens A, B, C, …, apply softmax over just those K logits, and get a probability for each option.</li>
</ol>
<p>The H2O-Lightning-4B model card sums this up as &ldquo;one forward pass and one output token&rdquo;. Since no text is generated, the cost is almost entirely input tokens; and since the output space is limited to K letters, the model cannot answer anything outside the options. This route has one more built-in advantage: the letter probabilities a large model gives in a multiple-choice format are already reasonably well calibrated (Kadavath et al., 2022), so decision fine-tuning starts from a good place.</p>
<h3 id="3-the-training-objective-why-it-must-be-a-strictly-proper-scoring-rule">3. The Training Objective: Why It Must Be a Strictly Proper Scoring Rule</h3>
<p>The probabilities a decision model outputs go straight into a program&rsquo;s branches, so we need more than right answers; we also need <strong>calibration</strong>: among all the answers given with 80% confidence, about 80% should actually be right.</p>
<p><strong>The first trap: rewarding only correct answers destroys calibration.</strong> Suppose the true distribution for a question is p (say, 70% A and 30% B) and the model reports a distribution q. If we treat the model as a policy that samples an answer from q and earns 1 point for a correct one, the expected reward is <code>Σ p_k · q_k</code>. That&rsquo;s linear in q, so the optimum puts all the probability on the most likely option, that is, sets the probability of A to 1. The model learns to be &ldquo;always 100% sure&rdquo;: accuracy stays the same, but the probabilities become meaningless.</p>
<p><strong>The fix is to use a strictly proper scoring rule as the objective.</strong> By definition, the expected score is maximized only when the reported distribution q equals the true distribution p, so telling the truth is the only optimal strategy (see Gneiting and Raftery&rsquo;s 2007 paper for the classic treatment). Four rules are common:</p>
<pre tabindex="0"><code>Let q = (q_1, …, q_K) be the model&#39;s distribution and y the label (one-hot or soft)

Log score (higher is better)           S_log(q, y) = Σ y_k · log q_k            the negative cross-entropy
Brier score (lower is better)          B(q, y)     = Σ (q_k − y_k)²
Spherical score (higher is better)     S_sph(q, y) = Σ y_k · q_k / ‖q‖          between 0 and 1, gentler gradients than the log score
Ranked probability score (lower)       RPS(q, y)   = Σ (Q_i − Y_i)² / (K − 1)   Q, Y are cumulative distributions; for ordinal score questions
</code></pre><p>Here&rsquo;s a fact that&rsquo;s often overlooked: <strong>cross-entropy is itself strictly proper</strong> (it <em>is</em> the log score). So why are neural networks trained with cross-entropy so often overconfident? The cause is not the loss but the finite training data: the model memorizes the training examples and pushes their probabilities toward 1, so on unseen data its confidence exceeds its actual accuracy (Guo et al.&rsquo;s 2017 paper analyzes this systematically). That leaves two things to do:</p>
<ol>
<li><strong>During training</strong>: use a strictly proper scoring rule as the objective; when the labels themselves are uncertain (annotators disagree, teacher models split their votes), use soft labels instead of one-hot ones so the model learns to be &ldquo;honestly uncertain&rdquo;; and don&rsquo;t train for too many epochs.</li>
<li><strong>After training</strong>: apply <strong>temperature scaling</strong> on a held-out dev set, <code>p = softmax(z / T)</code>, fitting one temperature T per question type. It doesn&rsquo;t change the ranking, so accuracy is unaffected; it only corrects the confidence.</li>
</ol>
<p><img alt="Overconfident vs. calibrated" loading="lazy" src="/blog/2026/train-your-own-jev/3-calibration.png"></p>
<p>Temperature scaling often makes a big difference. After refitting temperatures per &ldquo;question type × option count&rdquo;, Laya&rsquo;s shipped checkpoints dropped their mean ECE (expected calibration error) from 0.466 to 0.081. H2O-Lightning-4B, on the other hand, ships temperatures of 0.75 for choice, 0.8 for noul, and 0.65 for score, all below 1, which means its raw outputs are actually on the cautious side and need &ldquo;sharpening&rdquo;. The direction differs from model to model; you have to measure it rather than guess.</p>
<h3 id="4-rlcd-the-reinforcement-learning-recipe-laya-published">4. RLCD: The Reinforcement Learning Recipe Laya Published</h3>
<p>Jev calls its training method RLCD (Reinforcement Learning for Calibrated Decisions) but hasn&rsquo;t disclosed the details. Laya adopted the name and published a concrete implementation:</p>
<ul>
<li><strong>Reward</strong>: a combination of three strictly proper scores, <code>Reward = S_log + 0.5 × S_sph − RPS</code>, where RPS only applies to ordinal score questions;</li>
<li><strong>Exploration</strong>: for each question, 8 sets of Gaussian noise are added to the logits (the noise is centered across the options, because adding the same constant to all logits doesn&rsquo;t change the softmax), with the standard deviation decaying from 1.0 to 0.3;</li>
<li><strong>Advantage</strong>: the 8 rewards minus the group mean, divided by the group standard deviation, the same idea as GRPO&rsquo;s group baseline (for how GRPO works, see my earlier <a href="/blog/2026/ppo-vs-grpo/">PPO vs GRPO</a>);</li>
<li><strong>Update</strong>: a REINFORCE-style policy gradient using the Gaussian log-density, with no cross-entropy loss at all.</li>
</ul>
<p>My take: <strong>when the model outputs the full probability distribution, the scoring rule itself is differentiable. Taking its gradient directly (that is, using the scoring rule as the loss) and estimating that gradient with reinforcement learning point in the same direction in expectation, and the former has lower variance and is simpler.</strong> The RL formulation becomes truly irreplaceable when the reward can&rsquo;t be differentiated with respect to the output: for example, when it comes from whether a multi-step agent task eventually succeeds, from real user feedback, or from the multi-turn sales conversations Laya handles, where the reward is whether the deal finally closes and TD(λ) is needed to spread that outcome over the turns.</p>
<p>To test this, I ran two small experiments with the <code>reward()</code> function from the training script later in this article. First, with the target distribution set to [0.7, 0.2, 0.1], gradient ascent on the combined score converged to an output distribution of exactly [0.7, 0.2, 0.1], confirming that the combined score really &ldquo;only rewards the truth&rdquo;. Second, for the same set of logits, the gradient estimated the RLCD way (20,000 perturbations, σ = 0.05) pointed in almost exactly the same direction as the gradient from direct differentiation (cosine similarity 0.999).</p>
<p>So the training script in this article supports both: by default it optimizes the scoring rule directly (<code>--objective proper</code>), and you can switch to Laya-style RLCD (<code>--objective rlcd</code>) to compare for yourself.</p>
<h3 id="5-from-probabilities-to-actions-costs-set-the-threshold">5. From Probabilities to Actions: Costs Set the Threshold</h3>
<p>Once the probabilities are calibrated, you no longer have to guess when to act automatically and when to hand off to a human. Laya designed a cost matrix for its Act / Escalate head: +1 for a correct action, −3 for a wrong one, and −0.5 for escalating to a human. The expected payoff of acting is <code>p × 1 + (1 − p) × (−3)</code>, which beats −0.5 exactly when <code>p &gt; 0.625</code>. In general:</p>
<pre tabindex="0"><code>act threshold = (payoff of escalating − payoff of a wrong action) / (payoff of a right action − payoff of a wrong action)
              = (−0.5 − (−3)) / (1 − (−3)) = 0.625
</code></pre><p>As long as the probabilities are calibrated, this threshold falls straight out of the business costs, and a separately trained &ldquo;should I act?&rdquo; head isn&rsquo;t really necessary (Laya&rsquo;s README admits that its Act head currently carries almost no signal and recommends gating on confidence instead). H2O-Lightning-4B reports what thresholds do in practice on 3,474 held-out questions:</p>
<table>
	<thead>
			<tr>
					<th>Confidence threshold</th>
					<th>Escalated</th>
					<th>Accuracy on the rest</th>
					<th>Passed but wrong (share of all questions)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>None</td>
					<td>0%</td>
					<td>About 83%</td>
					<td>About 17%</td>
			</tr>
			<tr>
					<td>0.8</td>
					<td>37.7%</td>
					<td>95.8%</td>
					<td>2.6%</td>
			</tr>
			<tr>
					<td>0.9</td>
					<td>53.1%</td>
					<td>98.5%</td>
					<td>0.7%</td>
			</tr>
	</tbody>
</table>
<p>This is exactly the table you&rsquo;ll want to produce for your own model once it&rsquo;s trained.</p>
<h3 id="6-the-whole-workflow-at-a-glance">6. The Whole Workflow at a Glance</h3>
<p>Put into engineering terms, a complete training project looks roughly like this loop:</p>
<pre tabindex="0"><code>① Define the questions and acceptance criteria: metrics, thresholds, ship gates; write them down before seeing results
        │
② Find data → unify the format → dedupe and decontaminate → split by task
        │
③ Zero-shot baseline: read the base model&#39;s option-letter probabilities to know where you start
        │
④ LoRA fine-tuning: shuffled options + a strictly proper scoring rule (or RLCD)
        │
⑤ Fit temperatures on dev; score on held-out tasks, the public set, and a private lockbox
        │
⑥ Error analysis by slice → add data → back to ④
        │
⑦ Merge the weights, serve behind /v1/systemone, roll out gradually, keep monitoring
</code></pre><p>The rest of the article follows this order.</p>
<h2 id="part-2-data--where-to-find-it-and-how-to-prepare-it">Part 2: Data — Where to Find It and How to Prepare It</h2>
<p>Data sets the ceiling of a decision model. Almost every lesson the H2O-Lightning-4B model card lists is about data: keep a sealed evaluation set that never touches training, write the ship criteria down before seeing results, dedupe the training data against every evaluation set (both exact and near duplicates), hunt for gaps by topic and question type and fill them with new data, and take labels only from human annotation or from agreement among several independent strong models.</p>
<h3 id="1-where-the-data-comes-from">1. Where the Data Comes From</h3>
<table>
	<thead>
			<tr>
					<th>Source</th>
					<th>Examples</th>
					<th>Pros</th>
					<th>Caveats</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Public human-labeled datasets</td>
					<td>Intent: Banking77, CLINC150, MASSIVE (51 languages); topic: AG News, TNEWS; reasoning: BoolQ, MNLI, XNLI, OCNLI; sentiment and rating: SST-5, Yelp review stars, HelpSteer2; safety: toxicity detection, prompt injection</td>
					<td>Reliable labels, large volume, broad coverage</td>
					<td>Check every license, since many academic datasets are research-only; formats vary and must be rewritten as decision questions</td>
			</tr>
			<tr>
					<td>Your own business data</td>
					<td>Tickets, emails, review records, agent execution logs, plus human-reviewed outcomes</td>
					<td>Matches the production distribution; the most valuable</td>
					<td>Anonymize it; early labels can be noisy and need spot checks</td>
			</tr>
			<tr>
					<td>Synthetic data</td>
					<td>Generate states and questions in bulk with an open-source LLM, have several independent models answer them, and keep only the items they agree on, or use the vote shares as soft labels</td>
					<td>Quickly fills long-tail scenarios and weak spots</td>
					<td>Label with a single model and you learn that model&rsquo;s mistakes; some commercial APIs&rsquo; terms forbid using their outputs to train competing models, so check first</td>
			</tr>
			<tr>
					<td>Programmatically generated, verifiable data</td>
					<td>Date arithmetic, counting, rule checks (e.g., &ldquo;reject if the amount exceeds the threshold and there is no approval&rdquo;), state tracking in games and simulations</td>
					<td>Labels are computed by code: 100% correct, unlimited volume</td>
					<td>Items tend to look templated; vary the wording and structure a lot</td>
			</tr>
	</tbody>
</table>
<p>A few lessons worth borrowing:</p>
<ul>
<li><strong>Laya insists on &ldquo;zero synthetic data&rdquo;</strong>: all of its training data comes from human-labeled public datasets (support intents, natural language inference, content safety, jailbreaks and injections, rubric ratings, multi-turn conversation outcomes, and more). The reasoning: training a calibration model on model-generated labels only calibrates it to the &ldquo;teacher&rsquo;s&rdquo; mistakes.</li>
<li><strong>H2O-Lightning-4B&rsquo;s data mix</strong>: by use case, documents, policies, and rules 37%; numbers, dates, and probability 22%; games and simulations 12%; everyday language and knowledge 10%; support and operations 10%; safety and guardrails 7%; coding and tool calls 2%. By question type, choice 54%, noul 29%, score 16%. Over 99% of it is English. Training ran in three stages: broad coverage first, then a focus on long documents and numeric reasoning, and finally everything mixed back together.</li>
<li><strong>Task diversity matters more than per-task volume</strong>: the FLAN line of research found that the more kinds of tasks instruction tuning covers, the better models do on unseen tasks, and the same holds for decision models. For the same total amount of data, covering more distinct tasks usually helps zero-shot generalization more than piling up examples on a few.</li>
</ul>
<p>If you&rsquo;re building a specialist for one vertical domain, a few thousand to a few tens of thousands of high-quality labels already make a clear difference: Laya&rsquo;s typed-decisions checkpoint was fine-tuned on about 30,000 questions and went from 0.362 to 0.766 accuracy, and a community member fine-tuned Laya on a single 16 GB GPU into the action decider of a browser agent, raising the rate of picking the right target among about 45 candidate elements from 0.10 to 0.66.</p>
<h3 id="2-a-unified-data-format">2. A Unified Data Format</h3>
<p>I recommend reusing Jev&rsquo;s request format directly, with one decision per line:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;ticket_routing&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;Checkout returns HTTP 500 for every customer since 09:10.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which team should handle this ticket?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;Charges, invoices and refunds&#34;</span>,
</span></span><span style="display:flex;"><span>                           <span style="color:#f92672">&#34;engineering&#34;</span>: <span style="color:#e6db74">&#34;Bugs, errors and outages&#34;</span>,
</span></span><span style="display:flex;"><span>                           <span style="color:#f92672">&#34;support&#34;</span>: <span style="color:#e6db74">&#34;How-to and account questions&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#e6db74">&#34;engineering&#34;</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;refund_check&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: {<span style="color:#f92672">&#34;subject&#34;</span>: <span style="color:#e6db74">&#34;Charged twice&#34;</span>, <span style="color:#f92672">&#34;body&#34;</span>: <span style="color:#e6db74">&#34;Please refund the duplicate.&#34;</span>},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Does the customer ask for a refund?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;true&#34;</span>: <span style="color:#e6db74">&#34;yes, a refund is requested&#34;</span>, <span style="color:#f92672">&#34;false&#34;</span>: <span style="color:#e6db74">&#34;no refund is requested&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#66d9ef">true</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;urgency&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;Payroll export fails and payroll runs today.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How urgent is this request?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: [<span style="color:#e6db74">&#34;not urgent&#34;</span>, <span style="color:#e6db74">&#34;soon&#34;</span>, <span style="color:#e6db74">&#34;today&#34;</span>, <span style="color:#e6db74">&#34;blocking right now&#34;</span>]},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;answer&#34;</span>: <span style="color:#ae81ff">2</span>}
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;intent_soft&#34;</span>, <span style="color:#f92672">&#34;state&#34;</span>: <span style="color:#e6db74">&#34;I was billed twice and the app keeps crashing.&#34;</span>,
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;question&#34;</span>: {<span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#f92672">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;What is the main topic?&#34;</span>,
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">&#34;criteria&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#e6db74">&#34;payments&#34;</span>, <span style="color:#f92672">&#34;technical&#34;</span>: <span style="color:#e6db74">&#34;bugs&#34;</span>}},
</span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;target&#34;</span>: {<span style="color:#f92672">&#34;billing&#34;</span>: <span style="color:#ae81ff">0.6</span>, <span style="color:#f92672">&#34;technical&#34;</span>: <span style="color:#ae81ff">0.4</span>}}
</span></span></code></pre></div><p>A few conventions:</p>
<ul>
<li>The example is wrapped for readability; in the actual file each record sits on one line (JSONL);</li>
<li><code>answer</code> is the option name for choice, a boolean for noul, and the level index for score; samples with inherent uncertainty use <code>target</code> to give a soft label;</li>
<li>The <code>task</code> field records which task a sample comes from; splitting and slice analysis both rely on it;</li>
<li>This format is almost identical to that of JevBench&rsquo;s public items (which store the answer in an <code>expected</code> field), so the evaluation script later in the article can read JevBench&rsquo;s public items directly.</li>
</ul>
<h3 id="3-cleaning-and-augmentation">3. Cleaning and Augmentation</h3>
<p><strong>Cleaning</strong> comes down to three things:</p>
<ol>
<li><strong>Deduplication</strong>: do exact deduplication after normalization first, then near-duplicate removal with something like MinHash, so the same record doesn&rsquo;t show up several times with different punctuation;</li>
<li><strong>Decontamination</strong>: compare every evaluation set (your lockbox, JevBench&rsquo;s public items, and so on) with the training set and remove any overlap from training. JevBench requires models trained on its public items to disclose it, and models that score much better on the public items than on the sealed ones get penalized, so &ldquo;gaming&rdquo; the public items is pointless;</li>
<li><strong>Balancing</strong>: cap each task so no big dataset dominates training, and control the mix of question types, languages, and option counts.</li>
</ol>
<p><strong>Augmentation</strong> aims to teach the model to &ldquo;read the content, not the form&rdquo;:</p>
<table>
	<thead>
			<tr>
					<th>Augmentation</th>
					<th>What it guards against</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Shuffle the option order every time</td>
					<td>Position bias: Laya measured that 5 options with identical text get logits ranging from +1.68 down to −1.91 purely because of where they sit</td>
			</tr>
			<tr>
					<td>Rephrase the question; switch option names between meaningful words and neutral labels</td>
					<td>Keying on keywords, or being swayed by option names like <code>yes</code> and <code>true</code></td>
			</tr>
			<tr>
					<td>Switch the state between plain text and nested JSON; use synonyms for field names</td>
					<td>Only recognizing one input format</td>
			</tr>
			<tr>
					<td>Add irrelevant fields and distracting paragraphs to the state</td>
					<td>Attention being diluted by irrelevant content</td>
			</tr>
			<tr>
					<td>Add a &ldquo;cannot tell&rdquo; option and build samples with insufficient information</td>
					<td>Being forced to pick even when information is missing</td>
			</tr>
			<tr>
					<td>Build negated question pairs: ask Q and &ldquo;not Q&rdquo; about the same state</td>
					<td>The two probabilities not summing to 1</td>
			</tr>
			<tr>
					<td>Inject prompt-injection text into the state while keeping the label</td>
					<td>Being hijacked by instructions inside the state</td>
			</tr>
			<tr>
					<td>Sample &ldquo;the correct answer + a few distractors&rdquo; from a large option set</td>
					<td>A single fixed option count; this also solves having more options than letters</td>
			</tr>
	</tbody>
</table>
<p>Mind the order: <strong>split first, then augment</strong>, and augment only the training set. Otherwise variants of the same question end up in both the training and the evaluation sets, and the evaluation scores are inflated.</p>
<p><img alt="The data pipeline" loading="lazy" src="/blog/2026/train-your-own-jev/4-data-pipeline.png"></p>
<h3 id="4-splitting-always-hold-out-whole-tasks">4. Splitting: Always Hold Out Whole Tasks</h3>
<p>The most important ability of a decision model is generalizing to questions it hasn&rsquo;t seen, so evaluation needs several layers:</p>
<table>
	<thead>
			<tr>
					<th>Evaluation set</th>
					<th>How it&rsquo;s built</th>
					<th>What it tells you</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>dev</td>
					<td>About 5% randomly held out from the training tasks</td>
					<td>Whether training converges; used to fit temperatures</td>
			</tr>
			<tr>
					<td>held-out tasks</td>
					<td>Entire tasks excluded from training</td>
					<td>Zero-shot generalization, the closest match to how Jev is used</td>
			</tr>
			<tr>
					<td>Public benchmark</td>
					<td>JevBench&rsquo;s 231 public items</td>
					<td>Head-to-head comparison with other models</td>
			</tr>
			<tr>
					<td>lockbox</td>
					<td>Questions you wrote yourself and never use for tuning</td>
					<td>A one-time acceptance test before release, guarding against overfitting to your evaluation sets</td>
			</tr>
	</tbody>
</table>
<p>In a real project, each question type should have several tasks, so that after you hold out whole tasks, the training set still contains other tasks of the same type.</p>
<p>Below is a minimal data preparation script. It turns four public datasets into decision questions: Banking77 (intent, choice), BoolQ (yes/no, noul), SST-5 (sentiment, score), and OCNLI (Chinese natural language inference, choice). It then dedupes and decontaminates them and splits them by task:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># prepare_data.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Turn a few public datasets into decision records, remove duplicates and eval overlap, split by task.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> hashlib
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> random
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pathlib <span style="color:#f92672">import</span> Path
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> datasets <span style="color:#f92672">import</span> load_dataset
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>random<span style="color:#f92672">.</span>seed(<span style="color:#ae81ff">13</span>)
</span></span><span style="display:flex;"><span>SST5_LEVELS <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#34;very negative&#34;</span>, <span style="color:#e6db74">&#34;negative&#34;</span>, <span style="color:#e6db74">&#34;neutral&#34;</span>, <span style="color:#e6db74">&#34;positive&#34;</span>, <span style="color:#e6db74">&#34;very positive&#34;</span>]
</span></span><span style="display:flex;"><span>OCNLI <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;entailment&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设一定为真&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#e6db74">&#34;neutral&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设可能为真也可能为假&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#e6db74">&#34;contradiction&#34;</span>: <span style="color:#e6db74">&#34;前提为真时，假设一定为假&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">banking77</span>():
</span></span><span style="display:flex;"><span>    ds <span style="color:#f92672">=</span> load_dataset(<span style="color:#e6db74">&#34;mteb/banking77&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>)
</span></span><span style="display:flex;"><span>    intents <span style="color:#f92672">=</span> sorted(set(ds[<span style="color:#e6db74">&#34;label_text&#34;</span>]))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> ds:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># 77 intents do not fit 26 letters: show the gold intent among 9 random distractors</span>
</span></span><span style="display:flex;"><span>        shown <span style="color:#f92672">=</span> random<span style="color:#f92672">.</span>sample([i <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> intents <span style="color:#66d9ef">if</span> i <span style="color:#f92672">!=</span> row[<span style="color:#e6db74">&#34;label_text&#34;</span>]], <span style="color:#ae81ff">9</span>) <span style="color:#f92672">+</span> [row[<span style="color:#e6db74">&#34;label_text&#34;</span>]]
</span></span><span style="display:flex;"><span>        random<span style="color:#f92672">.</span>shuffle(shown)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;banking77&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;text&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;Which banking intent does the customer&#39;s message express?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: {i: i<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;_&#34;</span>, <span style="color:#e6db74">&#34; &#34;</span>) <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> shown}},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;label_text&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">boolq</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> load_dataset(<span style="color:#e6db74">&#34;google/boolq&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;boolq&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;passage&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: row[<span style="color:#e6db74">&#34;question&#34;</span>]<span style="color:#f92672">.</span>capitalize() <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: {<span style="color:#e6db74">&#34;true&#34;</span>: <span style="color:#e6db74">&#34;yes, according to the passage&#34;</span>,
</span></span><span style="display:flex;"><span>                                         <span style="color:#e6db74">&#34;false&#34;</span>: <span style="color:#e6db74">&#34;no, according to the passage&#34;</span>}},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;answer&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">sst5</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> load_dataset(<span style="color:#e6db74">&#34;SetFit/sst5&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;sst5&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: row[<span style="color:#e6db74">&#34;text&#34;</span>],
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;How positive is this movie review?&#34;</span>,
</span></span><span style="display:flex;"><span>                            <span style="color:#e6db74">&#34;criteria&#34;</span>: SST5_LEVELS},
</span></span><span style="display:flex;"><span>               <span style="color:#e6db74">&#34;answer&#34;</span>: row[<span style="color:#e6db74">&#34;label&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">ocnli</span>():
</span></span><span style="display:flex;"><span>    ds <span style="color:#f92672">=</span> load_dataset(<span style="color:#e6db74">&#34;clue/clue&#34;</span>, <span style="color:#e6db74">&#34;ocnli&#34;</span>, split<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;train&#34;</span>)
</span></span><span style="display:flex;"><span>    names <span style="color:#f92672">=</span> ds<span style="color:#f92672">.</span>features[<span style="color:#e6db74">&#34;label&#34;</span>]<span style="color:#f92672">.</span>names
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> ds:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> row[<span style="color:#e6db74">&#34;label&#34;</span>] <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0</span>:  <span style="color:#75715e"># -1 marks unlabeled rows</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">yield</span> {<span style="color:#e6db74">&#34;task&#34;</span>: <span style="color:#e6db74">&#34;ocnli&#34;</span>, <span style="color:#e6db74">&#34;state&#34;</span>: {<span style="color:#e6db74">&#34;前提&#34;</span>: row[<span style="color:#e6db74">&#34;sentence1&#34;</span>], <span style="color:#e6db74">&#34;假设&#34;</span>: row[<span style="color:#e6db74">&#34;sentence2&#34;</span>]},
</span></span><span style="display:flex;"><span>                   <span style="color:#e6db74">&#34;question&#34;</span>: {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;instructions&#34;</span>: <span style="color:#e6db74">&#34;假设和前提是什么关系？&#34;</span>, <span style="color:#e6db74">&#34;criteria&#34;</span>: OCNLI},
</span></span><span style="display:flex;"><span>                   <span style="color:#e6db74">&#34;answer&#34;</span>: names[row[<span style="color:#e6db74">&#34;label&#34;</span>]]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">key</span>(record):
</span></span><span style="display:flex;"><span>    text <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>dumps([record[<span style="color:#e6db74">&#34;state&#34;</span>], record[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;instructions&#34;</span>]], ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> hashlib<span style="color:#f92672">.</span>sha1(<span style="color:#e6db74">&#34; &#34;</span><span style="color:#f92672">.</span>join(text<span style="color:#f92672">.</span>lower()<span style="color:#f92672">.</span>split())<span style="color:#f92672">.</span>encode())<span style="color:#f92672">.</span>hexdigest()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">write</span>(path, rows):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(path, <span style="color:#e6db74">&#34;w&#34;</span>, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        f<span style="color:#f92672">.</span>writelines(json<span style="color:#f92672">.</span>dumps(r, ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span> <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> rows)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    held_out_tasks <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;banking77&#34;</span>}  <span style="color:#75715e"># whole tasks never trained on (choice is still covered by ocnli)</span>
</span></span><span style="display:flex;"><span>    banned <span style="color:#f92672">=</span> {key(json<span style="color:#f92672">.</span>loads(line)) <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> Path(<span style="color:#e6db74">&#34;eval&#34;</span>)<span style="color:#f92672">.</span>glob(<span style="color:#e6db74">&#34;*.jsonl&#34;</span>)
</span></span><span style="display:flex;"><span>              <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(p, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)}  <span style="color:#75715e"># JevBench public files, your lockbox, ...</span>
</span></span><span style="display:flex;"><span>    seen, train, dev, held_out <span style="color:#f92672">=</span> set(), [], [], []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> source <span style="color:#f92672">in</span> (banking77, boolq, sst5, ocnli):
</span></span><span style="display:flex;"><span>        rows <span style="color:#f92672">=</span> list(source())
</span></span><span style="display:flex;"><span>        random<span style="color:#f92672">.</span>shuffle(rows)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> rows[:<span style="color:#ae81ff">20000</span>]:  <span style="color:#75715e"># cap every task so no single dataset dominates the mix</span>
</span></span><span style="display:flex;"><span>            k <span style="color:#f92672">=</span> key(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> k <span style="color:#f92672">in</span> seen <span style="color:#f92672">or</span> k <span style="color:#f92672">in</span> banned:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>            seen<span style="color:#f92672">.</span>add(k)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;task&#34;</span>] <span style="color:#f92672">in</span> held_out_tasks:
</span></span><span style="display:flex;"><span>                held_out<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">elif</span> random<span style="color:#f92672">.</span>random() <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.05</span>:
</span></span><span style="display:flex;"><span>                dev<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>                train<span style="color:#f92672">.</span>append(r)
</span></span><span style="display:flex;"><span>    random<span style="color:#f92672">.</span>shuffle(train)
</span></span><span style="display:flex;"><span>    Path(<span style="color:#e6db74">&#34;data&#34;</span>)<span style="color:#f92672">.</span>mkdir(exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/train.jsonl&#34;</span>, train)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/dev.jsonl&#34;</span>, dev)
</span></span><span style="display:flex;"><span>    write(<span style="color:#e6db74">&#34;data/heldout_tasks.jsonl&#34;</span>, held_out)
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;train </span><span style="color:#e6db74">{</span>len(train)<span style="color:#e6db74">}</span><span style="color:#e6db74">  dev </span><span style="color:#e6db74">{</span>len(dev)<span style="color:#e6db74">}</span><span style="color:#e6db74">  held-out tasks </span><span style="color:#e6db74">{</span>len(held_out)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>Put JevBench&rsquo;s public files and your own lockbox in the <code>eval/</code> directory and run it to get three files under <code>data/</code>. My run produced 36,044 training records, 1,917 dev records, and 9,993 held-out-task records (Banking77). This is only a demo; a real project needs dozens to hundreds of tasks, but the process is exactly the same: <strong>every new data source is just one more conversion function like these.</strong></p>
<h2 id="part-3-gpu-resources--how-many-cards-do-you-need">Part 3: GPU Resources — How Many Cards Do You Need?</h2>
<h3 id="1-estimating-memory">1. Estimating Memory</h3>
<p>Training memory has three main parts: model weights, gradients plus optimizer state, and activations. The first two can be estimated in &ldquo;bytes per parameter&rdquo;:</p>
<table>
	<thead>
			<tr>
					<th>Training method</th>
					<th>Per parameter</th>
					<th>Breakdown</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Full fine-tuning</td>
					<td>16 bytes</td>
					<td>bf16 weights 2 + bf16 gradients 2 + fp32 master weights 4 + AdamW&rsquo;s two moments 8</td>
			</tr>
			<tr>
					<td>LoRA</td>
					<td>2 bytes</td>
					<td>Frozen bf16 weights; the adapter and its optimizer state usually take under 1 GB</td>
			</tr>
			<tr>
					<td>QLoRA</td>
					<td>0.5–0.6 bytes</td>
					<td>Frozen 4-bit weights</td>
			</tr>
	</tbody>
</table>
<p>By this rule, the memory needed before activations is roughly:</p>
<table>
	<thead>
			<tr>
					<th>Base model</th>
					<th>Full fine-tuning</th>
					<th>LoRA</th>
					<th>QLoRA</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Qwen3.5-0.8B</td>
					<td>About 13 GB</td>
					<td>About 1.6 GB</td>
					<td>About 0.5 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-2B</td>
					<td>About 32 GB</td>
					<td>About 4 GB</td>
					<td>About 1.1 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-4B</td>
					<td>About 64 GB</td>
					<td>About 8 GB</td>
					<td>About 2.2 GB</td>
			</tr>
			<tr>
					<td>Qwen3.5-9B</td>
					<td>About 144 GB</td>
					<td>About 18 GB</td>
					<td>About 5 GB</td>
			</tr>
			<tr>
					<td>Gemma-4-31B</td>
					<td>About 500 GB</td>
					<td>About 62 GB</td>
					<td>About 17 GB</td>
			</tr>
	</tbody>
</table>
<p>Activations scale with batch size and sequence length. With gradient checkpointing on, what remains is mostly the hidden states at each layer boundary. Taking Qwen3.5-4B (32 layers, hidden size 2560) as an example, one 4K-token sample needs about <code>32 × 4096 × 2560 × 2 bytes ≈ 0.7 GB</code>, or about 3 GB for a batch of 4, plus the peak from recomputing a single layer and CUDA&rsquo;s own overhead. So <strong>a single 24 GB card can usually handle LoRA on a 4B model</strong> (with sequences under 4K and a modest batch), while an 80 GB card lets you raise both the batch size and the sequence length.</p>
<p>There&rsquo;s a big trap specific to decision models here: <strong>the language-model head&rsquo;s logits</strong>. Qwen3.5 has a vocabulary of 248,320 tokens. If you compute logits at every position as in ordinary SFT, the fp32 logits of a single 4K-token sample alone take <code>4096 × 248320 × 4 bytes ≈ 4 GB</code>, plus another copy during backpropagation. A decision model only needs the logits at the last position, so passing <code>logits_to_keep=1</code> in the forward pass shrinks this to about 1 MB. The training script below does exactly that.</p>
<h3 id="2-estimating-time">2. Estimating Time</h3>
<p>Training compute can be estimated with a rule of thumb: about <code>6 × number of parameters</code> floating-point operations per training token (LoRA with gradient checkpointing: 2N forward, 2N recompute, 2N backward; full fine-tuning with gradient checkpointing is about 8N). For example, LoRA on Qwen3.5-4B with 200,000 questions averaging 600 tokens each, trained for 2 epochs:</p>
<pre tabindex="0"><code>Training tokens    ≈ 200,000 × 600 × 2 = 2.4e8
Training compute   ≈ 6 × 4e9 × 2.4e8 ≈ 5.8e18 FLOPs
H100 throughput    ≈ 989 TFLOPS (bf16 dense peak) × 35% utilization ≈ 350 TFLOPS
Training time      ≈ 5.8e18 / 3.5e14 ≈ 16,500 s ≈ 4.6 hours
</code></pre><p>The same job takes about 3 times as long on an A100. Actual utilization depends on sequence length, padding ratio, and kernel implementations, so trust your own profiler numbers. Qwen3.5 is a hybrid of Gated DeltaNet linear attention and gated full attention, so be sure to install <code>flash-linear-attention</code> and <code>causal-conv1d</code> before training; otherwise it falls back to a pure PyTorch implementation that is much slower (a warning is printed when the model loads).</p>
<h3 id="3-recommended-setups">3. Recommended Setups</h3>
<p><img alt="The hardware ladder from encoders to full fine-tuning" loading="lazy" src="/blog/2026/train-your-own-jev/5-gpu-ladder.png"></p>
<table>
	<thead>
			<tr>
					<th>Scenario</th>
					<th>Base model and method</th>
					<th>Recommended hardware</th>
					<th>Notes</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Encoder route</td>
					<td>ModernBERT-large / mmBERT + decision head</td>
					<td>2 T4s (Kaggle&rsquo;s free tier) or any 16 GB GPU</td>
					<td>Laya&rsquo;s official notebook: about 30,000 questions, 4 epochs, 4–5 hours</td>
			</tr>
			<tr>
					<td>Quick validation</td>
					<td>Qwen3.5-0.8B / 2B + LoRA</td>
					<td>One 24 GB GPU (RTX 4090, L4, A10)</td>
					<td>Get the pipeline working and run data experiments</td>
			</tr>
			<tr>
					<td>Main setup</td>
					<td>Qwen3.5-4B + LoRA</td>
					<td>One H100 / A100 80 GB</td>
					<td>The best value; 24 GB also works with short sequences</td>
			</tr>
			<tr>
					<td>Advanced</td>
					<td>Qwen3.5-9B + LoRA</td>
					<td>1–2 H100s</td>
					<td>Use 2 for long-context training</td>
			</tr>
			<tr>
					<td>Full fine-tuning</td>
					<td>4B–9B, all parameters</td>
					<td>8 H100s (FSDP or ZeRO-3)</td>
					<td>When you have lots of data and want every last bit of quality</td>
			</tr>
	</tbody>
</table>
<p>As for cost, at an on-demand H100 price of $2–3 per hour (JevBench&rsquo;s GPU cost calculator defaults to $2.89 per hour for an H100 PCIe), one LoRA run on a 4B model costs about $15. The real spending is in iteration: a project usually goes through dozens of &ldquo;change the data, retrain, evaluate&rdquo; rounds, so the total GPU bill is on the order of a few hundred dollars. <strong>Compute isn&rsquo;t the expensive part; data and evaluation are.</strong></p>
<p>Deployment is even cheaper. H2O-Lightning-4B&rsquo;s bf16 weights are about 9.1 GB; served with vLLM on a single 32 GB RTX PRO 4500, a standard decision takes about 30 ms at p50. JevBench&rsquo;s calculator estimates that self-hosting it on an RTX 5090 costs about $0.008 per 1,000 decisions, roughly a quarter of Jev&rsquo;s measured cost on JevBench ($0.032 per 1,000 decisions).</p>
<h2 id="part-4-choosing-a-base-model">Part 4: Choosing a Base Model</h2>
<h3 id="1-selection-criteria">1. Selection Criteria</h3>
<ol>
<li><strong>Zero-shot foundation</strong>: prefer instruct versions (aligned chat models); they already know how to answer multiple-choice questions and start higher, while base versions need more data to catch up. JevBench includes a few &ldquo;base model controls&rdquo; (no decision fine-tuning, raw logits) that show directly where different base models start.</li>
<li><strong>Language</strong>: for Chinese workloads, look at Qwen first; note that over 99% of H2O-Lightning-4B&rsquo;s training data is English, so test it yourself before using it on Chinese.</li>
<li><strong>Size and latency</strong>: decision models are meant to make quick calls in tens of milliseconds, and around 4B is currently the sweet spot for latency, cost, and capability.</li>
<li><strong>Context length</strong>: JevBench includes 23 extra-long items of 77,000–81,000 tokens; Jev and quite a few other models refuse them for exceeding their context, and they&rsquo;re scored as wrong. Qwen3.5 natively supports 262K, but the longer the sequences, the faster training memory and time climb.</li>
<li><strong>License</strong>: the whole Qwen3.5 family, Gemma 4, and ModernBERT are Apache 2.0, and mmBERT is MIT, which keeps commercial use simple.</li>
<li><strong>Ecosystem</strong>: training needs Transformers and PEFT support, and deployment needs vLLM, SGLang, and llama.cpp support. Also, each option letter must be a single token (the training script asserts this).</li>
</ol>
<h3 id="2-candidates">2. Candidates</h3>
<table>
	<thead>
			<tr>
					<th>Base model</th>
					<th>Size</th>
					<th>License</th>
					<th>Good for</th>
					<th>Community examples</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Qwen3.5-0.8B / 2B</td>
					<td>0.8B / 2B</td>
					<td>Apache 2.0</td>
					<td>On-device use, ultra-low latency, pipeline validation</td>
					<td>jiwo 0.8b, ClassOne Qwen 3.5 2B</td>
			</tr>
			<tr>
					<td>Qwen3.5-4B</td>
					<td>4B</td>
					<td>Apache 2.0</td>
					<td>The main recommendation; strong in Chinese</td>
					<td>H2O-Lightning-4B, Kahn1 4B</td>
			</tr>
			<tr>
					<td>Qwen3.5-9B</td>
					<td>9B</td>
					<td>Apache 2.0</td>
					<td>Stronger zero-shot ability</td>
					<td>Microsoft-Decision-1, ClassOne Qwen 3.5 9B</td>
			</tr>
			<tr>
					<td>Gemma-4-12B-it</td>
					<td>12B</td>
					<td>Apache 2.0</td>
					<td>Multilingual; balances capability and cost</td>
					<td>decider-12b, torchcast-decision-12b</td>
			</tr>
			<tr>
					<td>Gemma-4-31B-it / Qwen3.5-27B</td>
					<td>27B–31B</td>
					<td>Apache 2.0</td>
					<td>Aiming for the top of the leaderboard</td>
					<td>Quyet-1.0-Large, deck-31B</td>
			</tr>
			<tr>
					<td>ModernBERT-large / mmBERT</td>
					<td>0.3B–0.4B</td>
					<td>Apache 2.0 / MIT</td>
					<td>Encoder route; ultra-fast inference for a single task</td>
					<td>Laya</td>
			</tr>
	</tbody>
</table>
<h3 id="3-my-recommendations">3. My Recommendations</h3>
<ul>
<li><strong>Start with Qwen3.5-4B (instruct) + LoRA</strong>: it&rsquo;s strong in Chinese, the community has shown it can get close to Jev, and a single GPU is enough to train and serve it;</li>
<li><strong>Get the pipeline working on 0.8B / 2B first</strong>: validate the data format, evaluation scripts, and threshold strategy on a small model before moving to a larger base; it saves a lot of trial and error;</li>
<li><strong>Move up to 9B–31B only when you&rsquo;re chasing a higher capability score</strong>: the gains are real (Quyet-1.0-Large&rsquo;s intelligence score is more than 13 points above H2O-Lightning-4B&rsquo;s), but training and inference costs multiply too;</li>
<li><strong>Turn off thinking mode for Qwen3.5</strong>: it thinks before answering by default, so pass <code>enable_thinking=False</code> when building the prompt; the first token of the assistant&rsquo;s reply is then the answer letter.</li>
</ul>
<h2 id="part-5-writing-the-training-scripts-step-by-step">Part 5: Writing the Training Scripts, Step by Step</h2>
<p>This part gives a complete, ready-to-run set of code. It&rsquo;s deliberately lean and doesn&rsquo;t depend on any dedicated training framework, so you can follow what every step does.</p>
<h3 id="1-setting-up-the-environment">1. Setting Up the Environment</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install torch transformers peft accelerate datasets fastapi uvicorn
</span></span><span style="display:flex;"><span><span style="color:#75715e"># On GPU machines, also install the fast kernels for Qwen3.5&#39;s linear-attention layers</span>
</span></span><span style="display:flex;"><span>pip install flash-linear-attention causal-conv1d
</span></span></code></pre></div><p>The project layout:</p>
<pre tabindex="0"><code>my-jev/
├── prepare_data.py      # data preparation (previous part)
├── decision_format.py   # prompt rendering and single-pass scoring, shared by training, evaluation, and serving
├── train.py             # LoRA training
├── evaluate.py          # scoring and temperature fitting
├── serve.py             # /v1/systemone server
├── eval/                # JevBench public items and your own lockbox
└── data/                # output of prepare_data.py
</code></pre><h3 id="2-the-shared-module-one-question-one-forward-pass">2. The Shared Module: One Question, One Forward Pass</h3>
<p>Training, evaluation, and online serving must use <strong>exactly the same</strong> prompt format; otherwise the probabilities you trained won&rsquo;t hold up in production. So this part goes into a shared module first:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># decision_format.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Shared by training, evaluation and serving: render a decision as a prompt and read option probabilities.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> string
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> AutoModelForCausalLM, AutoTokenizer
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>LETTERS <span style="color:#f92672">=</span> string<span style="color:#f92672">.</span>ascii_uppercase  <span style="color:#75715e"># up to 26 options per question</span>
</span></span><span style="display:flex;"><span>SYSTEM <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;You are a decision model. Read the state and the question, &#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#e6db74">&#34;then answer with the letter of exactly one option.&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">load</span>(base, dtype<span style="color:#f92672">=</span>torch<span style="color:#f92672">.</span>bfloat16):
</span></span><span style="display:flex;"><span>    tok <span style="color:#f92672">=</span> AutoTokenizer<span style="color:#f92672">.</span>from_pretrained(base)
</span></span><span style="display:flex;"><span>    tok<span style="color:#f92672">.</span>padding_side <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;left&#34;</span>  <span style="color:#75715e"># every row then ends at its answer slot</span>
</span></span><span style="display:flex;"><span>    model <span style="color:#f92672">=</span> AutoModelForCausalLM<span style="color:#f92672">.</span>from_pretrained(base, dtype<span style="color:#f92672">=</span>dtype)  <span style="color:#75715e"># Qwen3.5 loads text-only here</span>
</span></span><span style="display:flex;"><span>    ids <span style="color:#f92672">=</span> [tok<span style="color:#f92672">.</span>encode(c, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> LETTERS]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> all(len(i) <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> ids), <span style="color:#e6db74">&#34;every option letter must be a single token&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tok, model, torch<span style="color:#f92672">.</span>tensor([i[<span style="color:#ae81ff">0</span>] <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> ids])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">options</span>(question):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;[(key, description)] in the caller&#39;s order; noul is shown as yes / no.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    kind, crit <span style="color:#f92672">=</span> question[<span style="color:#e6db74">&#34;type&#34;</span>], question<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;criteria&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>        crit <span style="color:#f92672">=</span> crit <span style="color:#f92672">or</span> {}
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [(<span style="color:#e6db74">&#34;yes&#34;</span>, crit<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;true&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)), (<span style="color:#e6db74">&#34;no&#34;</span>, crit<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;false&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>))]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [(str(i), desc) <span style="color:#66d9ef">for</span> i, desc <span style="color:#f92672">in</span> enumerate(crit)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> list(crit<span style="color:#f92672">.</span>items()) <span style="color:#66d9ef">if</span> isinstance(crit, dict) <span style="color:#66d9ef">else</span> [(c, <span style="color:#e6db74">&#34;&#34;</span>) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> crit]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">target</span>(record, opts):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Target distribution over opts: a soft label if the record has one, else one-hot.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    keys <span style="color:#f92672">=</span> [k <span style="color:#66d9ef">for</span> k, _ <span style="color:#f92672">in</span> opts]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;target&#34;</span> <span style="color:#f92672">in</span> record:  <span style="color:#75715e"># e.g. {&#34;billing&#34;: 0.7, &#34;technical&#34;: 0.3}</span>
</span></span><span style="display:flex;"><span>        dist <span style="color:#f92672">=</span> [float(record[<span style="color:#e6db74">&#34;target&#34;</span>]<span style="color:#f92672">.</span>get(k, <span style="color:#ae81ff">0.0</span>)) <span style="color:#66d9ef">for</span> k <span style="color:#f92672">in</span> keys]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>        ans <span style="color:#f92672">=</span> record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;answer&#34;</span>, record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;expected&#34;</span>))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> record[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>            ans <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;yes&#34;</span> <span style="color:#66d9ef">if</span> ans <span style="color:#f92672">in</span> (<span style="color:#66d9ef">True</span>, <span style="color:#e6db74">&#34;yes&#34;</span>, <span style="color:#e6db74">&#34;true&#34;</span>) <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;no&#34;</span>
</span></span><span style="display:flex;"><span>        dist <span style="color:#f92672">=</span> [<span style="color:#ae81ff">1.0</span> <span style="color:#66d9ef">if</span> k <span style="color:#f92672">==</span> str(ans) <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0.0</span> <span style="color:#66d9ef">for</span> k <span style="color:#f92672">in</span> keys]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> sum(dist) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;answer is not one of the options: </span><span style="color:#e6db74">{</span>record<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;id&#39;</span>, record[<span style="color:#e6db74">&#39;question&#39;</span>])<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [d <span style="color:#f92672">/</span> sum(dist) <span style="color:#66d9ef">for</span> d <span style="color:#f92672">in</span> dist]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">prompt</span>(tok, record, order, max_state_tokens<span style="color:#f92672">=</span><span style="color:#ae81ff">6000</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Chat prompt whose next token is the answer letter; slot s shows option order[s].&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    q, opts, state <span style="color:#f92672">=</span> record[<span style="color:#e6db74">&#34;question&#34;</span>], options(record[<span style="color:#e6db74">&#34;question&#34;</span>]), record[<span style="color:#e6db74">&#34;state&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> isinstance(state, str):
</span></span><span style="display:flex;"><span>        state <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>dumps(state, ensure_ascii<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, indent<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    ids <span style="color:#f92672">=</span> tok<span style="color:#f92672">.</span>encode(state, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> len(ids) <span style="color:#f92672">&gt;</span> max_state_tokens:  <span style="color:#75715e"># cut the state, never the options or the answer slot</span>
</span></span><span style="display:flex;"><span>        state <span style="color:#f92672">=</span> tok<span style="color:#f92672">.</span>decode(ids[:max_state_tokens]) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">[truncated]&#34;</span>
</span></span><span style="display:flex;"><span>    lines <span style="color:#f92672">=</span> [<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>LETTERS[s]<span style="color:#e6db74">}</span><span style="color:#e6db74">. </span><span style="color:#e6db74">{</span>opts[i][<span style="color:#ae81ff">0</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">+</span> (<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;: </span><span style="color:#e6db74">{</span>opts[i][<span style="color:#ae81ff">1</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#66d9ef">if</span> opts[i][<span style="color:#ae81ff">1</span>] <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>             <span style="color:#66d9ef">for</span> s, i <span style="color:#f92672">in</span> enumerate(order)]
</span></span><span style="display:flex;"><span>    user <span style="color:#f92672">=</span> (<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;&lt;state&gt;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">{</span>state<span style="color:#e6db74">}</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&lt;/state&gt;</span><span style="color:#ae81ff">\n\n</span><span style="color:#e6db74">Question (</span><span style="color:#e6db74">{</span>q[<span style="color:#e6db74">&#39;type&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">): </span><span style="color:#e6db74">{</span>q[<span style="color:#e6db74">&#39;instructions&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;Options:</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>join(lines) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n\n</span><span style="color:#e6db74">Answer with the letter of one option.&#34;</span>)
</span></span><span style="display:flex;"><span>    messages <span style="color:#f92672">=</span> [{<span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;system&#34;</span>, <span style="color:#e6db74">&#34;content&#34;</span>: SYSTEM}, {<span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;user&#34;</span>, <span style="color:#e6db74">&#34;content&#34;</span>: user}]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tok<span style="color:#f92672">.</span>apply_chat_template(messages, tokenize<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, add_generation_prompt<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>                                   enable_thinking<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">option_logits</span>(model, tok, letter_ids, prompts, ks, device):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;One forward pass for a batch: logits of the shown option letters at the answer slot.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    enc <span style="color:#f92672">=</span> tok(prompts, return_tensors<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;pt&#34;</span>, padding<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>to(device)
</span></span><span style="display:flex;"><span>    out <span style="color:#f92672">=</span> model(input_ids<span style="color:#f92672">=</span>enc<span style="color:#f92672">.</span>input_ids, attention_mask<span style="color:#f92672">=</span>enc<span style="color:#f92672">.</span>attention_mask, logits_to_keep<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    z <span style="color:#f92672">=</span> out<span style="color:#f92672">.</span>logits[:, <span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, letter_ids[: max(ks)]<span style="color:#f92672">.</span>to(device)]<span style="color:#f92672">.</span>float()
</span></span><span style="display:flex;"><span>    mask <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>arange(z<span style="color:#f92672">.</span>shape[<span style="color:#ae81ff">1</span>], device<span style="color:#f92672">=</span>device)[<span style="color:#66d9ef">None</span>, :] <span style="color:#f92672">&lt;</span> torch<span style="color:#f92672">.</span>tensor(ks, device<span style="color:#f92672">=</span>device)[:, <span style="color:#66d9ef">None</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> z<span style="color:#f92672">.</span>masked_fill(<span style="color:#f92672">~</span>mask, <span style="color:#f92672">-</span><span style="color:#ae81ff">1e4</span>), mask
</span></span></code></pre></div><p>Key points:</p>
<ul>
<li><strong>Left padding</strong>: prompts in a batch have different lengths; after padding on the left, the last position of every row is the answer slot, so with <code>logits_to_keep=1</code> the language-model head only computes one position. Because Qwen3.5 has linear-attention layers, I checked this specifically: scoring a question on its own versus inside a left-padded batch changes the logits by at most 6×10⁻⁵;</li>
<li><strong>Loading only the text part</strong>: Qwen3.5 is natively multimodal, but loading it with <code>AutoModelForCausalLM</code> gives you the text-only <code>Qwen3_5ForCausalLM</code> without the vision encoder, which saves memory and keeps LoRA off the vision layers;</li>
<li><strong>Softmax over just the K letters</strong>: the code takes the logits for A, B, C, … and fills the unused slots with a very small value, so the output always lands on a valid option;</li>
<li><strong>Truncate only the state when it&rsquo;s too long</strong>: the question, the options, and the answer slot must stay intact;</li>
<li><strong>noul is always rendered as two options, &ldquo;yes / no&rdquo;</strong>, and the returned P(yes) is the noul answer; score levels stay in order, which RPS needs;</li>
<li><strong>Assert that every letter is a single token</strong>: when you switch base models, this line catches problems immediately. In the Qwen3.5 tokenizer, A through Z are all single tokens, and appending a letter at the answer slot adds exactly one token.</li>
</ul>
<h3 id="3-the-main-training-script">3. The Main Training Script</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># train.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;LoRA fine-tuning of a decision model with strictly proper scoring rules, or Laya-style RLCD.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> argparse
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> math
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> random
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> accelerate <span style="color:#f92672">import</span> Accelerator
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> LoraConfig, get_peft_model
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> torch.utils.data <span style="color:#f92672">import</span> DataLoader
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> transformers <span style="color:#f92672">import</span> get_cosine_schedule_with_warmup
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> load, option_logits, options, prompt, target
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">reward</span>(z, mask, y, ordinal):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Laya&#39;s composite of strictly proper scores: log + 0.5 * spherical - RPS (RPS for score only).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    logq <span style="color:#f92672">=</span> z<span style="color:#f92672">.</span>log_softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> logq<span style="color:#f92672">.</span>exp()
</span></span><span style="display:flex;"><span>    s_log <span style="color:#f92672">=</span> (y <span style="color:#f92672">*</span> logq)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)                      <span style="color:#75715e"># log score = minus cross-entropy</span>
</span></span><span style="display:flex;"><span>    s_sph <span style="color:#f92672">=</span> (y <span style="color:#f92672">*</span> q)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> q<span style="color:#f92672">.</span>norm(dim<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)        <span style="color:#75715e"># spherical score, bounded in [0, 1]</span>
</span></span><span style="display:flex;"><span>    k <span style="color:#f92672">=</span> mask<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>clamp(min<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    rps <span style="color:#f92672">=</span> ((q<span style="color:#f92672">.</span>cumsum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">-</span> y<span style="color:#f92672">.</span>cumsum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (k <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)  <span style="color:#75715e"># ranked probability score</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> s_log <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span> <span style="color:#f92672">*</span> s_sph <span style="color:#f92672">-</span> ordinal <span style="color:#f92672">*</span> rps
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">rlcd_loss</span>(z, mask, y, ordinal, sigma, group<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Policy gradient over Gaussian-perturbed logits with a group-mean baseline, as Laya describes RLCD.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    m <span style="color:#f92672">=</span> mask<span style="color:#f92672">.</span>float()
</span></span><span style="display:flex;"><span>    eps <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>randn(group, <span style="color:#f92672">*</span>z<span style="color:#f92672">.</span>shape, device<span style="color:#f92672">=</span>z<span style="color:#f92672">.</span>device) <span style="color:#f92672">*</span> m
</span></span><span style="display:flex;"><span>    eps <span style="color:#f92672">=</span> (eps <span style="color:#f92672">-</span> eps<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, keepdim<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>) <span style="color:#f92672">/</span> m<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>, keepdim<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)) <span style="color:#f92672">*</span> m  <span style="color:#75715e"># softmax ignores a common shift</span>
</span></span><span style="display:flex;"><span>    sample <span style="color:#f92672">=</span> z<span style="color:#f92672">.</span>detach() <span style="color:#f92672">+</span> eps
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> torch<span style="color:#f92672">.</span>no_grad():
</span></span><span style="display:flex;"><span>        r <span style="color:#f92672">=</span> reward(sample, mask, y, ordinal)            <span style="color:#75715e"># [group, batch]</span>
</span></span><span style="display:flex;"><span>        adv <span style="color:#f92672">=</span> (r <span style="color:#f92672">-</span> r<span style="color:#f92672">.</span>mean(<span style="color:#ae81ff">0</span>)) <span style="color:#f92672">/</span> (r<span style="color:#f92672">.</span>std(<span style="color:#ae81ff">0</span>) <span style="color:#f92672">+</span> <span style="color:#ae81ff">1e-6</span>)
</span></span><span style="display:flex;"><span>    logp <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>(((sample <span style="color:#f92672">-</span> z) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> m)<span style="color:#f92672">.</span>sum(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (<span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> sigma<span style="color:#f92672">**</span><span style="color:#ae81ff">2</span>)  <span style="color:#75715e"># Gaussian log-density up to a constant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#f92672">-</span>(adv <span style="color:#f92672">*</span> logp)<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Collate</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__init__</span>(self, tok, max_state_tokens):
</span></span><span style="display:flex;"><span>        self<span style="color:#f92672">.</span>tok, self<span style="color:#f92672">.</span>max_state_tokens <span style="color:#f92672">=</span> tok, max_state_tokens
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">__call__</span>(self, records):
</span></span><span style="display:flex;"><span>        prompts, ks, ys, ordinal <span style="color:#f92672">=</span> [], [], [], []
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> records:
</span></span><span style="display:flex;"><span>            opts <span style="color:#f92672">=</span> options(r[<span style="color:#e6db74">&#34;question&#34;</span>])
</span></span><span style="display:flex;"><span>            order <span style="color:#f92672">=</span> list(range(len(opts)))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>:  <span style="color:#75715e"># score levels keep their order, RPS relies on it</span>
</span></span><span style="display:flex;"><span>                random<span style="color:#f92672">.</span>shuffle(order)
</span></span><span style="display:flex;"><span>            y <span style="color:#f92672">=</span> target(r, opts)
</span></span><span style="display:flex;"><span>            prompts<span style="color:#f92672">.</span>append(prompt(self<span style="color:#f92672">.</span>tok, r, order, self<span style="color:#f92672">.</span>max_state_tokens))
</span></span><span style="display:flex;"><span>            ys<span style="color:#f92672">.</span>append([y[i] <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> order])
</span></span><span style="display:flex;"><span>            ks<span style="color:#f92672">.</span>append(len(opts))
</span></span><span style="display:flex;"><span>            ordinal<span style="color:#f92672">.</span>append(float(r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>))
</span></span><span style="display:flex;"><span>        y <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>zeros(len(records), max(ks))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> row, t <span style="color:#f92672">in</span> enumerate(ys):
</span></span><span style="display:flex;"><span>            y[row, : len(t)] <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>tensor(t)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;prompts&#34;</span>: prompts, <span style="color:#e6db74">&#34;ks&#34;</span>: ks, <span style="color:#e6db74">&#34;y&#34;</span>: y, <span style="color:#e6db74">&#34;ordinal&#34;</span>: torch<span style="color:#f92672">.</span>tensor(ordinal)}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    ap <span style="color:#f92672">=</span> argparse<span style="color:#f92672">.</span>ArgumentParser()
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--base&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--train&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;data/train.jsonl&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--out&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;out/my-jev&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--objective&#34;</span>, choices<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;proper&#34;</span>, <span style="color:#e6db74">&#34;rlcd&#34;</span>], default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;proper&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--epochs&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--lr&#34;</span>, type<span style="color:#f92672">=</span>float, default<span style="color:#f92672">=</span><span style="color:#ae81ff">1e-4</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--batch-size&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">4</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--grad-accum&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--lora-r&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">32</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--max-state-tokens&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">3000</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--mixed-precision&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;bf16&#34;</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> ap<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    acc <span style="color:#f92672">=</span> Accelerator(mixed_precision<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>mixed_precision, gradient_accumulation_steps<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>grad_accum)
</span></span><span style="display:flex;"><span>    tok, model, letter_ids <span style="color:#f92672">=</span> load(args<span style="color:#f92672">.</span>base)
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">.</span>gradient_checkpointing_enable(gradient_checkpointing_kwargs<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;use_reentrant&#34;</span>: <span style="color:#66d9ef">False</span>})
</span></span><span style="display:flex;"><span>    model <span style="color:#f92672">=</span> get_peft_model(model, LoraConfig(
</span></span><span style="display:flex;"><span>        r<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>lora_r, lora_alpha<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span> <span style="color:#f92672">*</span> args<span style="color:#f92672">.</span>lora_r, lora_dropout<span style="color:#f92672">=</span><span style="color:#ae81ff">0.05</span>, target_modules<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;all-linear&#34;</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>is_main_process:
</span></span><span style="display:flex;"><span>        model<span style="color:#f92672">.</span>print_trainable_parameters()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    records <span style="color:#f92672">=</span> [json<span style="color:#f92672">.</span>loads(line) <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(args<span style="color:#f92672">.</span>train, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)]
</span></span><span style="display:flex;"><span>    loader <span style="color:#f92672">=</span> DataLoader(records, batch_size<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>batch_size, shuffle<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>                        collate_fn<span style="color:#f92672">=</span>Collate(tok, args<span style="color:#f92672">.</span>max_state_tokens))
</span></span><span style="display:flex;"><span>    opt <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>optim<span style="color:#f92672">.</span>AdamW([p <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> model<span style="color:#f92672">.</span>parameters() <span style="color:#66d9ef">if</span> p<span style="color:#f92672">.</span>requires_grad], lr<span style="color:#f92672">=</span>args<span style="color:#f92672">.</span>lr, weight_decay<span style="color:#f92672">=</span><span style="color:#ae81ff">0.0</span>)
</span></span><span style="display:flex;"><span>    total <span style="color:#f92672">=</span> args<span style="color:#f92672">.</span>epochs <span style="color:#f92672">*</span> math<span style="color:#f92672">.</span>ceil(len(loader) <span style="color:#f92672">/</span> args<span style="color:#f92672">.</span>grad_accum)
</span></span><span style="display:flex;"><span>    sched <span style="color:#f92672">=</span> get_cosine_schedule_with_warmup(opt, max(<span style="color:#ae81ff">1</span>, int(<span style="color:#ae81ff">0.03</span> <span style="color:#f92672">*</span> total)), total)
</span></span><span style="display:flex;"><span>    model, opt, loader, sched <span style="color:#f92672">=</span> acc<span style="color:#f92672">.</span>prepare(model, opt, loader, sched)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    step, n_batches <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>, args<span style="color:#f92672">.</span>epochs <span style="color:#f92672">*</span> len(loader)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> epoch <span style="color:#f92672">in</span> range(args<span style="color:#f92672">.</span>epochs):
</span></span><span style="display:flex;"><span>        model<span style="color:#f92672">.</span>train()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> batch <span style="color:#f92672">in</span> loader:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">with</span> acc<span style="color:#f92672">.</span>accumulate(model):
</span></span><span style="display:flex;"><span>                z, mask <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, batch[<span style="color:#e6db74">&#34;prompts&#34;</span>], batch[<span style="color:#e6db74">&#34;ks&#34;</span>], acc<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>objective <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;rlcd&#34;</span>:
</span></span><span style="display:flex;"><span>                    sigma <span style="color:#f92672">=</span> <span style="color:#ae81ff">1.0</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">0.7</span> <span style="color:#f92672">*</span> step <span style="color:#f92672">/</span> n_batches  <span style="color:#75715e"># exploration decays from 1.0 to 0.3</span>
</span></span><span style="display:flex;"><span>                    loss <span style="color:#f92672">=</span> rlcd_loss(z, mask, batch[<span style="color:#e6db74">&#34;y&#34;</span>], batch[<span style="color:#e6db74">&#34;ordinal&#34;</span>], sigma)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>                    loss <span style="color:#f92672">=</span> <span style="color:#f92672">-</span>reward(z, mask, batch[<span style="color:#e6db74">&#34;y&#34;</span>], batch[<span style="color:#e6db74">&#34;ordinal&#34;</span>])<span style="color:#f92672">.</span>mean()
</span></span><span style="display:flex;"><span>                acc<span style="color:#f92672">.</span>backward(loss)
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>sync_gradients:
</span></span><span style="display:flex;"><span>                    acc<span style="color:#f92672">.</span>clip_grad_norm_(model<span style="color:#f92672">.</span>parameters(), <span style="color:#ae81ff">1.0</span>)
</span></span><span style="display:flex;"><span>                opt<span style="color:#f92672">.</span>step()
</span></span><span style="display:flex;"><span>                sched<span style="color:#f92672">.</span>step()
</span></span><span style="display:flex;"><span>                opt<span style="color:#f92672">.</span>zero_grad()
</span></span><span style="display:flex;"><span>            step <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> step <span style="color:#f92672">%</span> <span style="color:#ae81ff">20</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>                acc<span style="color:#f92672">.</span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;epoch </span><span style="color:#e6db74">{</span>epoch <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> step </span><span style="color:#e6db74">{</span>step<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>n_batches<span style="color:#e6db74">}</span><span style="color:#e6db74"> loss </span><span style="color:#e6db74">{</span>loss<span style="color:#f92672">.</span>item()<span style="color:#e6db74">:</span><span style="color:#e6db74">.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>        acc<span style="color:#f92672">.</span>wait_for_everyone()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> acc<span style="color:#f92672">.</span>is_main_process:
</span></span><span style="display:flex;"><span>            acc<span style="color:#f92672">.</span>unwrap_model(model)<span style="color:#f92672">.</span>save_pretrained(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>args<span style="color:#f92672">.</span>out<span style="color:#e6db74">}</span><span style="color:#e6db74">/epoch</span><span style="color:#e6db74">{</span>epoch <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    acc<span style="color:#f92672">.</span>end_training()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>What this code does:</p>
<ol>
<li><strong>Training objective</strong>: <code>reward()</code> is the Laya combined score described earlier (log score + 0.5 × spherical score − RPS). The default <code>--objective proper</code> uses its negative directly as the loss, while <code>--objective rlcd</code> follows Laya&rsquo;s approach and runs a policy gradient with 8 Gaussian perturbations and a group baseline, with the exploration noise decaying from 1.0 to 0.3. Because of the spherical term, the loss for choice and noul questions bottoms out at −0.5 (fully confident and correct), so negative values in the log are normal. The RLCD loss, meanwhile, is a surrogate objective for the policy gradient, and its value means nothing on its own. With either objective, go by the evaluation metrics;</li>
<li><strong>Shuffled options</strong>: <code>Collate</code> reshuffles the options of choice and noul questions every time it builds a batch, moving the labels along with them. This is the simplest and most effective defense against position bias; score questions keep their level order;</li>
<li><strong>LoRA</strong>: <code>target_modules=&quot;all-linear&quot;</code> covers every linear layer in the attention, Gated DeltaNet, and MLP blocks. On Qwen3.5-0.8B with r=8, that&rsquo;s about 5.41 million trainable parameters, just 0.71% of the total;</li>
<li><strong>Saving memory</strong>: gradient checkpointing is on, and only the logits at the answer slot are computed;</li>
<li><strong>One codebase for one GPU or many</strong>: Accelerate handles devices, mixed precision, and gradient accumulation, and switches to data parallelism on multiple GPUs.</li>
</ol>
<h3 id="4-launching-training">4. Launching Training</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Single GPU: effective batch = 4 × 8 = 32 questions</span>
</span></span><span style="display:flex;"><span>python train.py --base Qwen/Qwen3.5-4B --train data/train.jsonl --out out/my-jev
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 8-GPU data parallelism: effective batch = 4 × 2 × 8 = 64 questions</span>
</span></span><span style="display:flex;"><span>accelerate launch --num_processes <span style="color:#ae81ff">8</span> train.py --base Qwen/Qwen3.5-4B <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --train data/train.jsonl --out out/my-jev --grad-accum <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Comparison run: switch to Laya-style RLCD</span>
</span></span><span style="display:flex;"><span>python train.py --base Qwen/Qwen3.5-4B --train data/train.jsonl --out out/my-jev-rlcd --objective rlcd
</span></span></code></pre></div><p>Each epoch saves a LoRA adapter (<code>out/my-jev/epoch1</code>, <code>epoch2</code>, …), and the evaluation in the next part decides which one to keep. Hyperparameters can start from here:</p>
<table>
	<thead>
			<tr>
					<th>Parameter</th>
					<th>Starting point</th>
					<th>Notes</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Learning rate</td>
					<td>1e-4 for LoRA, 1e-5 for full fine-tuning</td>
					<td>Cosine decay with 3% warmup</td>
			</tr>
			<tr>
					<td>LoRA rank / alpha</td>
					<td>32 / 64</td>
					<td>Can go up to 64 with lots of data and many tasks</td>
			</tr>
			<tr>
					<td>Effective batch</td>
					<td>32–64 questions</td>
					<td><code>batch-size × grad-accum × number of GPUs</code></td>
			</tr>
			<tr>
					<td>Epochs</td>
					<td>1–3</td>
					<td>Watch the log loss on held-out tasks and stop once it gets worse</td>
			</tr>
			<tr>
					<td>State truncation length</td>
					<td>3,000 tokens</td>
					<td>Raise it for long-document tasks, and include enough long samples in the training data</td>
			</tr>
			<tr>
					<td>Option order</td>
					<td>Reshuffled every batch</td>
					<td>Score questions keep their level order</td>
			</tr>
	</tbody>
</table>
<p>I smoke-tested this script on Qwen3.5-0.8B on a CPU (a few dozen questions, one run with each training objective) and confirmed that it trains and saves correctly. Do the real training on GPUs.</p>
<h2 id="part-6-evaluation--how-do-you-know-the-model-is-any-good">Part 6: Evaluation — How Do You Know the Model Is Any Good?</h2>
<h3 id="1-which-metrics-to-watch">1. Which Metrics to Watch</h3>
<p>Accuracy alone is far from enough. A proper decision-model evaluation should include at least these metrics:</p>
<table>
	<thead>
			<tr>
					<th>Metric</th>
					<th>Meaning</th>
					<th>How to read it</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Accuracy</td>
					<td>Whether the most probable option is correct</td>
					<td>Always read it against the baseline of &ldquo;always guess the most common answer&rdquo;</td>
			</tr>
			<tr>
					<td>Chance-corrected accuracy</td>
					<td><code>(accuracy − chance accuracy) / (1 − chance accuracy)</code></td>
					<td>The idea behind JevBench&rsquo;s intelligence score: 0 means guessing, 100 means all correct</td>
			</tr>
			<tr>
					<td>Log loss</td>
					<td>Mean negative log probability of the correct answer</td>
					<td>The training objective itself; lower is better, and it punishes &ldquo;confidently wrong&rdquo; the hardest</td>
			</tr>
			<tr>
					<td>Brier score</td>
					<td>Sum of squared differences between the probabilities and the label</td>
					<td>Lower is better; more robust than log loss</td>
			</tr>
			<tr>
					<td>ECE</td>
					<td>Weighted mean gap between confidence and accuracy across 10 confidence bins</td>
					<td>The core calibration metric; the closer to 0, the better</td>
			</tr>
			<tr>
					<td>RPS</td>
					<td>Error between cumulative distributions on score questions</td>
					<td>For ordinal questions only: being off by one level costs less than being off by three</td>
			</tr>
			<tr>
					<td>Reversed-order flip rate</td>
					<td>Share of answers that change when the option order is reversed</td>
					<td>A direct measure of position bias</td>
			</tr>
			<tr>
					<td>Threshold curve</td>
					<td>Escalation rate and accuracy of the passed answers at different confidence thresholds</td>
					<td>Used to pick the production threshold; this is the H2O table from earlier</td>
			</tr>
			<tr>
					<td>Latency</td>
					<td>p50 / p95 per question</td>
					<td>The lifeline of a decision model</td>
			</tr>
	</tbody>
</table>
<p>Position bias is no small matter. Research has long shown that LLMs answering multiple-choice questions favor particular option positions and option letters (Zheng et al., 2023). JevBench recorded an example as well: on questions that judge whether an answer is adequate, open-alternative-jev scored 72% with the option order its author chose (<code>A. yes, B. no</code>) and only 21% with the two options swapped. Beyond the reversed-order flip rate in the script, it&rsquo;s worth adding a few consistency checks before going live: whether the answer holds when the question is rephrased, whether the probabilities of a question and its negation sum to about 1, and whether injected text in the state sways the answer.</p>
<h3 id="2-the-evaluation-script">2. The Evaluation Script</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># evaluate.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Evaluate a decision model: accuracy, chance-corrected accuracy, calibration, order robustness, latency.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> argparse
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> statistics
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> PeftModel
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> load, option_logits, options, prompt, target
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">predict</span>(model, tok, letter_ids, records, batch_size<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>, reverse<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Logits over each record&#39;s options, in the caller&#39;s order.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">0</span>, len(records), batch_size):
</span></span><span style="display:flex;"><span>        chunk, orders <span style="color:#f92672">=</span> records[i : i <span style="color:#f92672">+</span> batch_size], []
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> chunk:
</span></span><span style="display:flex;"><span>            order <span style="color:#f92672">=</span> list(range(len(options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> reverse <span style="color:#f92672">and</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>                order<span style="color:#f92672">.</span>reverse()
</span></span><span style="display:flex;"><span>            orders<span style="color:#f92672">.</span>append(order)
</span></span><span style="display:flex;"><span>        prompts <span style="color:#f92672">=</span> [prompt(tok, r, o) <span style="color:#66d9ef">for</span> r, o <span style="color:#f92672">in</span> zip(chunk, orders)]
</span></span><span style="display:flex;"><span>        z, _ <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, prompts, [len(o) <span style="color:#66d9ef">for</span> o <span style="color:#f92672">in</span> orders], model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> row, order <span style="color:#f92672">in</span> enumerate(orders):
</span></span><span style="display:flex;"><span>            logits <span style="color:#f92672">=</span> torch<span style="color:#f92672">.</span>empty(len(order))
</span></span><span style="display:flex;"><span>            logits[order] <span style="color:#f92672">=</span> z[row, : len(order)]<span style="color:#f92672">.</span>cpu()  <span style="color:#75715e"># slot s showed option order[s]</span>
</span></span><span style="display:flex;"><span>            result<span style="color:#f92672">.</span>append(logits)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">ece</span>(conf, correct, bins<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>):
</span></span><span style="display:flex;"><span>    groups <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> c, ok <span style="color:#f92672">in</span> zip(conf, correct):
</span></span><span style="display:flex;"><span>        groups<span style="color:#f92672">.</span>setdefault(min(int(c <span style="color:#f92672">*</span> bins), bins <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>), [])<span style="color:#f92672">.</span>append((c, ok))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> sum(len(g) <span style="color:#f92672">/</span> len(conf) <span style="color:#f92672">*</span> abs(statistics<span style="color:#f92672">.</span>mean(o <span style="color:#66d9ef">for</span> _, o <span style="color:#f92672">in</span> g) <span style="color:#f92672">-</span> statistics<span style="color:#f92672">.</span>mean(c <span style="color:#66d9ef">for</span> c, _ <span style="color:#f92672">in</span> g))
</span></span><span style="display:flex;"><span>               <span style="color:#66d9ef">for</span> g <span style="color:#f92672">in</span> groups<span style="color:#f92672">.</span>values())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">summary</span>(rows):
</span></span><span style="display:flex;"><span>    conf <span style="color:#f92672">=</span> [p<span style="color:#f92672">.</span>max()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, _ <span style="color:#f92672">in</span> rows]
</span></span><span style="display:flex;"><span>    correct <span style="color:#f92672">=</span> [float(p<span style="color:#f92672">.</span>argmax() <span style="color:#f92672">==</span> y<span style="color:#f92672">.</span>argmax()) <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows]
</span></span><span style="display:flex;"><span>    acc <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean(correct)
</span></span><span style="display:flex;"><span>    chance <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean(<span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> len(p) <span style="color:#66d9ef">for</span> _, p, _ <span style="color:#f92672">in</span> rows)
</span></span><span style="display:flex;"><span>    out <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;n&#34;</span>: len(rows), <span style="color:#e6db74">&#34;accuracy&#34;</span>: acc, <span style="color:#e6db74">&#34;chance_corrected&#34;</span>: (acc <span style="color:#f92672">-</span> chance) <span style="color:#f92672">/</span> (<span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> chance),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;log_loss&#34;</span>: statistics<span style="color:#f92672">.</span>mean(<span style="color:#f92672">-</span>(y <span style="color:#f92672">*</span> p<span style="color:#f92672">.</span>clamp_min(<span style="color:#ae81ff">1e-12</span>)<span style="color:#f92672">.</span>log())<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;brier&#34;</span>: statistics<span style="color:#f92672">.</span>mean(((p <span style="color:#f92672">-</span> y) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item() <span style="color:#66d9ef">for</span> _, p, y <span style="color:#f92672">in</span> rows),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;ece&#34;</span>: ece(conf, correct)}
</span></span><span style="display:flex;"><span>    ordinal <span style="color:#f92672">=</span> [(p, y) <span style="color:#66d9ef">for</span> kind, p, y <span style="color:#f92672">in</span> rows <span style="color:#66d9ef">if</span> kind <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ordinal:
</span></span><span style="display:flex;"><span>        out[<span style="color:#e6db74">&#34;rps&#34;</span>] <span style="color:#f92672">=</span> statistics<span style="color:#f92672">.</span>mean((((p<span style="color:#f92672">.</span>cumsum(<span style="color:#ae81ff">0</span>) <span style="color:#f92672">-</span> y<span style="color:#f92672">.</span>cumsum(<span style="color:#ae81ff">0</span>)) <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>sum() <span style="color:#f92672">/</span> (len(p) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>item()
</span></span><span style="display:flex;"><span>                                     <span style="color:#66d9ef">for</span> p, y <span style="color:#f92672">in</span> ordinal)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> out
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">metrics</span>(records, logits, temps):
</span></span><span style="display:flex;"><span>    rows <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r, z <span style="color:#f92672">in</span> zip(records, logits):
</span></span><span style="display:flex;"><span>        kind <span style="color:#f92672">=</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>]
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> (z <span style="color:#f92672">/</span> temps<span style="color:#f92672">.</span>get(kind, <span style="color:#ae81ff">1.0</span>))<span style="color:#f92672">.</span>softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>        rows<span style="color:#f92672">.</span>append((kind, p, torch<span style="color:#f92672">.</span>tensor(target(r, options(r[<span style="color:#e6db74">&#34;question&#34;</span>])))))
</span></span><span style="display:flex;"><span>    report <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;all&#34;</span>: summary(rows)}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> kind <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> any(row[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">==</span> kind <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> rows):
</span></span><span style="display:flex;"><span>            report[kind] <span style="color:#f92672">=</span> summary([row <span style="color:#66d9ef">for</span> row <span style="color:#f92672">in</span> rows <span style="color:#66d9ef">if</span> row[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">==</span> kind])
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> report
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">fit_temperatures</span>(records, logits):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;One temperature per question type, chosen to minimise log loss (fit on dev data only).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    grid, temps <span style="color:#f92672">=</span> [round(<span style="color:#ae81ff">0.3</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.05</span> <span style="color:#f92672">*</span> i, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> range(<span style="color:#ae81ff">55</span>)], {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> kind <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>):
</span></span><span style="display:flex;"><span>        pairs <span style="color:#f92672">=</span> [(z, torch<span style="color:#f92672">.</span>tensor(target(r, options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))))
</span></span><span style="display:flex;"><span>                 <span style="color:#66d9ef">for</span> r, z <span style="color:#f92672">in</span> zip(records, logits) <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> kind]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> pairs:
</span></span><span style="display:flex;"><span>            temps[kind] <span style="color:#f92672">=</span> min(grid, key<span style="color:#f92672">=</span><span style="color:#66d9ef">lambda</span> t: sum(<span style="color:#f92672">-</span>(y <span style="color:#f92672">*</span> (z <span style="color:#f92672">/</span> t)<span style="color:#f92672">.</span>log_softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>))<span style="color:#f92672">.</span>sum()<span style="color:#f92672">.</span>item()
</span></span><span style="display:flex;"><span>                                                      <span style="color:#66d9ef">for</span> z, y <span style="color:#f92672">in</span> pairs))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> temps
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">latency_ms</span>(model, tok, letter_ids, records, n<span style="color:#f92672">=</span><span style="color:#ae81ff">50</span>):
</span></span><span style="display:flex;"><span>    times <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> records[:n]:
</span></span><span style="display:flex;"><span>        k <span style="color:#f92672">=</span> len(options(r[<span style="color:#e6db74">&#34;question&#34;</span>]))
</span></span><span style="display:flex;"><span>        text <span style="color:#f92672">=</span> prompt(tok, r, list(range(k)))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available():
</span></span><span style="display:flex;"><span>            torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>synchronize()
</span></span><span style="display:flex;"><span>        start <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter()
</span></span><span style="display:flex;"><span>        option_logits(model, tok, letter_ids, [text], [k], model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available():
</span></span><span style="display:flex;"><span>            torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>synchronize()
</span></span><span style="display:flex;"><span>        times<span style="color:#f92672">.</span>append((time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> start) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>)
</span></span><span style="display:flex;"><span>    times<span style="color:#f92672">.</span>sort()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;p50&#34;</span>: times[len(times) <span style="color:#f92672">//</span> <span style="color:#ae81ff">2</span>], <span style="color:#e6db74">&#34;p95&#34;</span>: times[min(len(times) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>, int(<span style="color:#ae81ff">0.95</span> <span style="color:#f92672">*</span> len(times)))]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>():
</span></span><span style="display:flex;"><span>    ap <span style="color:#f92672">=</span> argparse<span style="color:#f92672">.</span>ArgumentParser()
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--base&#34;</span>, default<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--adapter&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;LoRA directory; omit it to score the raw base model&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--data&#34;</span>, nargs<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;+&#34;</span>, required<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--fit-temperature&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;fit temperatures on --data (dev only) and save them here&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--temperatures&#34;</span>, help<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;temperatures fitted earlier&#34;</span>)
</span></span><span style="display:flex;"><span>    ap<span style="color:#f92672">.</span>add_argument(<span style="color:#e6db74">&#34;--batch-size&#34;</span>, type<span style="color:#f92672">=</span>int, default<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>)
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> ap<span style="color:#f92672">.</span>parse_args()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    tok, model, letter_ids <span style="color:#f92672">=</span> load(args<span style="color:#f92672">.</span>base)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>adapter:
</span></span><span style="display:flex;"><span>        model <span style="color:#f92672">=</span> PeftModel<span style="color:#f92672">.</span>from_pretrained(model, args<span style="color:#f92672">.</span>adapter)<span style="color:#f92672">.</span>merge_and_unload()
</span></span><span style="display:flex;"><span>    model<span style="color:#f92672">.</span>to(<span style="color:#e6db74">&#34;cuda&#34;</span> <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available() <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;cpu&#34;</span>)<span style="color:#f92672">.</span>eval()
</span></span><span style="display:flex;"><span>    records <span style="color:#f92672">=</span> [json<span style="color:#f92672">.</span>loads(line) <span style="color:#66d9ef">for</span> path <span style="color:#f92672">in</span> args<span style="color:#f92672">.</span>data <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(path, encoding<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>)]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    logits <span style="color:#f92672">=</span> predict(model, tok, letter_ids, records, args<span style="color:#f92672">.</span>batch_size)
</span></span><span style="display:flex;"><span>    temps <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(args<span style="color:#f92672">.</span>temperatures)) <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>temperatures <span style="color:#66d9ef">else</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> args<span style="color:#f92672">.</span>fit_temperature:
</span></span><span style="display:flex;"><span>        temps <span style="color:#f92672">=</span> fit_temperatures(records, logits)
</span></span><span style="display:flex;"><span>        json<span style="color:#f92672">.</span>dump(temps, open(args<span style="color:#f92672">.</span>fit_temperature, <span style="color:#e6db74">&#34;w&#34;</span>), indent<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    flipped <span style="color:#f92672">=</span> predict(model, tok, letter_ids, records, args<span style="color:#f92672">.</span>batch_size, reverse<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    flips <span style="color:#f92672">=</span> [bool(a<span style="color:#f92672">.</span>argmax() <span style="color:#f92672">!=</span> b<span style="color:#f92672">.</span>argmax())
</span></span><span style="display:flex;"><span>             <span style="color:#66d9ef">for</span> r, a, b <span style="color:#f92672">in</span> zip(records, logits, flipped) <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;question&#34;</span>][<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;score&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    report <span style="color:#f92672">=</span> metrics(records, logits, temps)
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;flip_rate_reversed_options&#34;</span>] <span style="color:#f92672">=</span> sum(flips) <span style="color:#f92672">/</span> max(<span style="color:#ae81ff">1</span>, len(flips))
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;temperatures&#34;</span>] <span style="color:#f92672">=</span> temps
</span></span><span style="display:flex;"><span>    report[<span style="color:#e6db74">&#34;latency_ms&#34;</span>] <span style="color:#f92672">=</span> latency_ms(model, tok, letter_ids, records)
</span></span><span style="display:flex;"><span>    print(json<span style="color:#f92672">.</span>dumps(report, indent<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main()
</span></span></code></pre></div><p>Typical usage takes three steps:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 1) Zero-shot baseline: no adapter, read the base model&#39;s letter probabilities to see where you start</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --data data/dev.jsonl data/heldout_tasks.jsonl
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 2) Evaluate the trained model on dev and fit one temperature per question type</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --data data/dev.jsonl --fit-temperature out/my-jev/temperatures.json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 3) Score held-out tasks and the JevBench public items with those temperatures (fit temperatures on dev only)</span>
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --temperatures out/my-jev/temperatures.json --data data/heldout_tasks.jsonl
</span></span><span style="display:flex;"><span>python evaluate.py --base Qwen/Qwen3.5-4B --adapter out/my-jev/epoch2 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --temperatures out/my-jev/temperatures.json --data eval/easy.jsonl eval/original.jsonl eval/hard.jsonl
</span></span></code></pre></div><p>As an example, here are the results (excerpted, rounded to three decimals) of the raw Qwen3.5-0.8B model (no adapter) on JevBench&rsquo;s public easy and original sets, 120 questions in total. I ran it on my Mac&rsquo;s CPU, so the latency numbers mean nothing and are left out:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;all&#34;</span>:    {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">120</span>, <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.642</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.488</span>, <span style="color:#f92672">&#34;log_loss&#34;</span>: <span style="color:#ae81ff">1.326</span>, <span style="color:#f92672">&#34;brier&#34;</span>: <span style="color:#ae81ff">0.523</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.108</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;choice&#34;</span>: {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">72</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.694</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.614</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.117</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;noul&#34;</span>:   {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">36</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.583</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.167</span>, <span style="color:#f92672">&#34;ece&#34;</span>: <span style="color:#ae81ff">0.220</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;score&#34;</span>:  {<span style="color:#f92672">&#34;n&#34;</span>: <span style="color:#ae81ff">12</span>,  <span style="color:#f92672">&#34;accuracy&#34;</span>: <span style="color:#ae81ff">0.500</span>, <span style="color:#f92672">&#34;chance_corrected&#34;</span>: <span style="color:#ae81ff">0.333</span>, <span style="color:#f92672">&#34;rps&#34;</span>: <span style="color:#ae81ff">0.151</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;flip_rate_reversed_options&#34;</span>: <span style="color:#ae81ff">0.620</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A few things stand out:</p>
<ul>
<li>noul accuracy is only 58.3%, just 16.7% after removing chance, so the model is essentially guessing;</li>
<li><strong>The reversed-order flip rate is a whopping 62%</strong>: simply reversing the option order changes the answer on more than 60% of the questions. I also tried a single question by hand. With the state &ldquo;Checkout returns HTTP 500 for every customer since 09:10.&rdquo; and the question &ldquo;Is a production system down?&rdquo;, P(yes) was 0.18 with the options ordered yes, no, and 0.45 with them ordered no, yes. Small models have far more position bias than you&rsquo;d expect, which is exactly why training keeps shuffling the options and why evaluation must report this metric;</li>
<li>For comparison, H2O-Lightning-4B gets 119 of the same 120 questions right. The two models differ in size, so the gap isn&rsquo;t all down to fine-tuning, but it shows how much ground decision fine-tuning has to cover.</li>
</ul>
<h3 id="3-comparing-with-the-models-on-jevbench">3. Comparing with the Models on JevBench</h3>
<p>JevBench&rsquo;s GitHub repository publishes 231 questions (under <code>datasets/public/</code>: 48 easy, 72 original, and 111 hard). Their format matches the one used in this article, so <code>evaluate.py</code> can read them directly. For reference, H2O-Lightning-4B answers 205 of these 231 correctly (88.7%): 48/48 easy, 71/72 original, and 86/111 hard.</p>
<p>Keep three things in mind, though:</p>
<ol>
<li><strong>The public items are only a reference</strong>: the leaderboard scores are based on 1,500 questions per release (1,200 sealed items plus 300 public ones, each half of the intelligence score), with chance correction, difficulty weighting, and a penalty for public/sealed gaps, so accuracy on the public items can&rsquo;t be converted into a leaderboard score;</li>
<li><strong>Never train on the public items</strong>: JevBench requires models trained on its public items to disclose it, and the decontamination in <code>prepare_data.py</code> is there for exactly that;</li>
<li><strong>Submit if you want to be ranked</strong>: once you submit a model on the JevBench website, the maintainers run the full sealed set on their own GPUs.</li>
</ol>
<p>If you want to test exactly the way JevBench does, first wrap your model in the same HTTP interface as Jev, then call it with the official evaluation tool.</p>
<h3 id="4-serving-a-jev-compatible-v1systemone">4. Serving: A Jev-Compatible /v1/systemone</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># serve.py</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;A minimal /v1/systemone server (Jev wire format) in front of the fine-tuned model.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> torch
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> FastAPI, HTTPException
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> peft <span style="color:#f92672">import</span> PeftModel
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> decision_format <span style="color:#f92672">import</span> LETTERS, load, option_logits, options, prompt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>BASE <span style="color:#f92672">=</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;BASE_MODEL&#34;</span>, <span style="color:#e6db74">&#34;Qwen/Qwen3.5-4B&#34;</span>)
</span></span><span style="display:flex;"><span>ADAPTER <span style="color:#f92672">=</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;ADAPTER&#34;</span>, <span style="color:#e6db74">&#34;out/my-jev/epoch2&#34;</span>)
</span></span><span style="display:flex;"><span>TEMPS <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;TEMPERATURES&#34;</span>])) <span style="color:#66d9ef">if</span> os<span style="color:#f92672">.</span>environ<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;TEMPERATURES&#34;</span>) <span style="color:#66d9ef">else</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>tok, model, letter_ids <span style="color:#f92672">=</span> load(BASE)
</span></span><span style="display:flex;"><span>model <span style="color:#f92672">=</span> PeftModel<span style="color:#f92672">.</span>from_pretrained(model, ADAPTER)<span style="color:#f92672">.</span>merge_and_unload()
</span></span><span style="display:flex;"><span>model<span style="color:#f92672">.</span>to(<span style="color:#e6db74">&#34;cuda&#34;</span> <span style="color:#66d9ef">if</span> torch<span style="color:#f92672">.</span>cuda<span style="color:#f92672">.</span>is_available() <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;cpu&#34;</span>)<span style="color:#f92672">.</span>eval()
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.post</span>(<span style="color:#e6db74">&#34;/v1/systemone&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@torch.no_grad</span>()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">systemone</span>(body: dict):
</span></span><span style="display:flex;"><span>    questions <span style="color:#f92672">=</span> body<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;questions&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> isinstance(questions, dict) <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">&lt;</span> len(questions) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">32</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> HTTPException(<span style="color:#ae81ff">422</span>, <span style="color:#e6db74">&#34;questions must be an object with 1 to 32 entries&#34;</span>)
</span></span><span style="display:flex;"><span>    names, prompts, ks <span style="color:#f92672">=</span> list(questions), [], []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> name <span style="color:#f92672">in</span> names:
</span></span><span style="display:flex;"><span>        q <span style="color:#f92672">=</span> questions[name]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            k <span style="color:#f92672">=</span> len(options(q))
</span></span><span style="display:flex;"><span>            ok <span style="color:#f92672">=</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>) <span style="color:#f92672">and</span> isinstance(q[<span style="color:#e6db74">&#34;instructions&#34;</span>], str)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> (<span style="color:#a6e22e">KeyError</span>, <span style="color:#a6e22e">TypeError</span>, <span style="color:#a6e22e">AttributeError</span>):
</span></span><span style="display:flex;"><span>            ok <span style="color:#f92672">=</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> ok <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">&lt;=</span> k <span style="color:#f92672">&lt;=</span> len(LETTERS):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> HTTPException(<span style="color:#ae81ff">422</span>, <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>name<span style="color:#e6db74">}</span><span style="color:#e6db74">: malformed question or unsupported option count&#34;</span>)
</span></span><span style="display:flex;"><span>        prompts<span style="color:#f92672">.</span>append(prompt(tok, {<span style="color:#e6db74">&#34;state&#34;</span>: body<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;state&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>), <span style="color:#e6db74">&#34;question&#34;</span>: q}, list(range(k))))
</span></span><span style="display:flex;"><span>        ks<span style="color:#f92672">.</span>append(k)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    z, _ <span style="color:#f92672">=</span> option_logits(model, tok, letter_ids, prompts, ks, model<span style="color:#f92672">.</span>device)
</span></span><span style="display:flex;"><span>    answers <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> row, name <span style="color:#f92672">in</span> enumerate(names):
</span></span><span style="display:flex;"><span>        q, k <span style="color:#f92672">=</span> questions[name], ks[row]
</span></span><span style="display:flex;"><span>        p <span style="color:#f92672">=</span> (z[row, :k] <span style="color:#f92672">/</span> TEMPS<span style="color:#f92672">.</span>get(q[<span style="color:#e6db74">&#34;type&#34;</span>], <span style="color:#ae81ff">1.0</span>))<span style="color:#f92672">.</span>softmax(<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>)<span style="color:#f92672">.</span>tolist()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;noul&#34;</span>:
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;noul&#34;</span>, <span style="color:#e6db74">&#34;noul&#34;</span>: p[<span style="color:#ae81ff">0</span>]}  <span style="color:#75715e"># P(yes)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> q[<span style="color:#e6db74">&#34;type&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;score&#34;</span>:
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;score&#34;</span>, <span style="color:#e6db74">&#34;score&#34;</span>: sum(i <span style="color:#f92672">*</span> x <span style="color:#66d9ef">for</span> i, x <span style="color:#f92672">in</span> enumerate(p)),
</span></span><span style="display:flex;"><span>                             <span style="color:#e6db74">&#34;probabilities&#34;</span>: {str(i): x <span style="color:#66d9ef">for</span> i, x <span style="color:#f92672">in</span> enumerate(p)}}
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            probs <span style="color:#f92672">=</span> {key: x <span style="color:#66d9ef">for</span> (key, _), x <span style="color:#f92672">in</span> zip(options(q), p)}
</span></span><span style="display:flex;"><span>            best <span style="color:#f92672">=</span> max(probs, key<span style="color:#f92672">=</span>probs<span style="color:#f92672">.</span>get)
</span></span><span style="display:flex;"><span>            answers[name] <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;choice&#34;</span>, <span style="color:#e6db74">&#34;choice&#34;</span>: best, <span style="color:#e6db74">&#34;probabilities&#34;</span>: probs,
</span></span><span style="display:flex;"><span>                             <span style="color:#e6db74">&#34;confidence&#34;</span>: (k <span style="color:#f92672">*</span> probs[best] <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>) <span style="color:#f92672">/</span> (k <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>)}
</span></span><span style="display:flex;"><span>    used <span style="color:#f92672">=</span> sum(len(ids) <span style="color:#66d9ef">for</span> ids <span style="color:#f92672">in</span> tok(prompts, add_special_tokens<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>input_ids)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;answers&#34;</span>: answers, <span style="color:#e6db74">&#34;usage&#34;</span>: {<span style="color:#e6db74">&#34;input_tokens&#34;</span>: used, <span style="color:#e6db74">&#34;output_tokens&#34;</span>: <span style="color:#ae81ff">0</span>}}
</span></span></code></pre></div><p>Start the server, then check it with curl:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ADAPTER<span style="color:#f92672">=</span>out/my-jev/epoch2 TEMPERATURES<span style="color:#f92672">=</span>out/my-jev/temperatures.json <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  uvicorn serve:app --host 127.0.0.1 --port <span style="color:#ae81ff">8741</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>curl -s http://127.0.0.1:8741/v1/systemone -H <span style="color:#e6db74">&#39;Content-Type: application/json&#39;</span> -d <span style="color:#e6db74">&#39;{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;state&#34;: &#34;Checkout returns HTTP 500 for every customer since 09:10.&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;questions&#34;: {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;team&#34;: {&#34;type&#34;: &#34;choice&#34;, &#34;instructions&#34;: &#34;Which team should handle this?&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">             &#34;criteria&#34;: {&#34;billing&#34;: &#34;charges and refunds&#34;, &#34;engineering&#34;: &#34;bugs and outages&#34;}},
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;outage&#34;: {&#34;type&#34;: &#34;noul&#34;, &#34;instructions&#34;: &#34;Is a production system down?&#34;}
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">}&#39;</span>
</span></span></code></pre></div><p>The response follows Jev&rsquo;s format: every answer carries a <code>type</code>; choice returns <code>choice</code>, <code>probabilities</code>, and a <code>confidence</code> computed with Jev&rsquo;s formula <code>(K·p_max − 1)/(K − 1)</code>; score returns the expected level plus probabilities keyed by level index; noul returns P(yes) directly; and <code>usage</code> only counts input tokens. These are exactly the fields that JevBench&rsquo;s official evaluation tool (its <code>typesafe</code> adapter) parses, so you can test with it directly, and existing Jev clients usually only need a new <code>baseUrl</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone https://github.com/fstandhartinger/jevbench <span style="color:#f92672">&amp;&amp;</span> cd jevbench
</span></span><span style="display:flex;"><span>python -m jevbench.cli run <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tasks datasets/public/easy.jsonl,datasets/public/original.jsonl,datasets/public/hard.jsonl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --adapter typesafe --endpoint http://127.0.0.1:8741 --key-env <span style="color:#e6db74">&#39;&#39;</span> --model my-jev <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --cost-basis self_hosted_gpu --reserve-usd <span style="color:#ae81ff">0</span> --delay-s <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --results RUN/results.jsonl --raw-dir RUN/raw --ledger RUN/ledger.jsonl --manifest RUN/manifest.json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Summarize the results</span>
</span></span><span style="display:flex;"><span>python -m jevbench.cli summarize <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --tasks datasets/public/easy.jsonl,datasets/public/original.jsonl,datasets/public/hard.jsonl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --results RUN/results.jsonl --public-export RUN/summary.json
</span></span></code></pre></div><p>This server is only meant for evaluation and demos: it handles one request at a time and has no authentication. For production, I recommend doing what H2O-Lightning-4B does: hand the merged model to vLLM (continuous batching, prefix caching), put a thin protocol-translation layer in front of it, add authentication, rate limiting, and request size limits, and never expose it directly to the internet.</p>
<h2 id="part-7-tuning--what-to-do-when-the-scores-disappoint">Part 7: Tuning — What to Do When the Scores Disappoint</h2>
<h3 id="1-establish-baselines-then-look-for-problems-by-slice">1. Establish Baselines, Then Look for Problems by Slice</h3>
<p>Before tuning anything, pin down two numbers: the zero-shot baseline (no fine-tuning, reading the base model&rsquo;s letter probabilities directly) and the score of &ldquo;always guess the most common answer&rdquo;. The trained model must be compared against both, or inflated accuracy will fool you.</p>
<p>Then break the results down by slice: question type, task, language, option count, state length, and confidence bucket. Problems the overall score hides are often obvious within a slice. H2O calls this &ldquo;gap hunting&rdquo;: comparing against other systems by topic and question type, running adversarial sets, and then adding new, verified data where it&rsquo;s needed.</p>
<h3 id="2-symptoms-and-remedies">2. Symptoms and Remedies</h3>
<table>
	<thead>
			<tr>
					<th>Symptom</th>
					<th>Likely cause</th>
					<th>Remedy</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Great on training tasks, poor on held-out tasks</td>
					<td>Overfitting to the training tasks&rsquo; wording and templates</td>
					<td>Add more kinds of tasks and phrasings; fewer epochs and a lower learning rate; stop early based on held-out-task log loss</td>
			</tr>
			<tr>
					<td>Decent accuracy but high ECE</td>
					<td>Overconfidence from overtraining, or noisy labels</td>
					<td>Temperature scaling; fewer epochs; soft labels for disputed samples</td>
			</tr>
			<tr>
					<td>Answers change when the options are reversed</td>
					<td>Position bias</td>
					<td>Shuffle options at every step during training; average over several orders at inference (Laya measured this cutting order-induced answer changes from 16.3% to 6.1%); or use a parallel layout where options can&rsquo;t see each other</td>
			</tr>
			<tr>
					<td>noul always leans toward &ldquo;yes&rdquo; or &ldquo;no&rdquo;</td>
					<td>Label-word bias, unbalanced positives and negatives</td>
					<td>Balance positives and negatives; build paired &ldquo;question + negated question&rdquo; data; check the <code>criteria</code> wording for slant</td>
			</tr>
			<tr>
					<td>Large errors on score questions</td>
					<td>Little ordinal data, vague level descriptions</td>
					<td>Add ordinal rating data and give RPS more weight; spell out the criteria for each level</td>
			</tr>
			<tr>
					<td>Accuracy drops when there are many options</td>
					<td>More options than letters, or options that look alike</td>
					<td>Narrow the candidates first with embeddings or group elimination (Laya&rsquo;s tournament raised BANKING77 from 0.430 to 0.610); or split it into a two-level &ldquo;coarse + fine&rdquo; question</td>
			</tr>
			<tr>
					<td>Accuracy drops on long documents</td>
					<td>No long samples in training, or truncation</td>
					<td>Add a dedicated long-document training stage (H2O&rsquo;s second stage does exactly this); check the truncation rate and raise <code>max_state_tokens</code></td>
			</tr>
			<tr>
					<td>Mistakes on counting, dates, and arithmetic</td>
					<td>An inherent weakness of language models</td>
					<td>Add programmatically generated, verifiable data; better yet, leave the computation to code and only the semantic judgment to the model</td>
			</tr>
			<tr>
					<td>Poor results in Chinese and other non-English languages</td>
					<td>Mostly English training data</td>
					<td>Add data in those languages; pick a base model with stronger multilingual ability; track metrics per language</td>
			</tr>
			<tr>
					<td>Zero-shot ability drops after fine-tuning</td>
					<td>Catastrophic forgetting</td>
					<td>Lower the learning rate and LoRA rank; mix in a little general instruction data; compare held-out-task scores before and after fine-tuning</td>
			</tr>
			<tr>
					<td>Latency too high</td>
					<td>The base model is too large, or the prompts are too long</td>
					<td>Switch to a smaller base; trim the state; enable prefix caching; quantize to FP8 (in H2O&rsquo;s test, the FP8 version got only 3 fewer questions right, a statistically insignificant difference)</td>
			</tr>
	</tbody>
</table>
<h3 id="3-closing-the-loop">3. Closing the Loop</h3>
<p><img alt="The iteration flywheel: train, calibrate, evaluate, analyze errors, add data" loading="lazy" src="/blog/2026/train-your-own-jev/6-eval-and-iterate.png"></p>
<p>Finally, a few process recommendations that matter more than any single hyperparameter:</p>
<ol>
<li><strong>Write the ship criteria down first</strong>: for example, &ldquo;chance-corrected accuracy on the lockbox no lower than the previous version, ECE no higher than 0.05 after temperature scaling, a reversed-order flip rate no higher than 5%, and p95 latency under 100 ms.&rdquo; Setting the criteria before seeing the results is the only way to avoid moving the goalposts to fit them.</li>
<li><strong>Use the lockbox only once per release</strong>: day-to-day tuning looks only at dev and the held-out tasks; once the lockbox gets used over and over, it becomes just another dev set.</li>
<li><strong>Change one variable at a time</strong>: if you change the data, the hyperparameters, and the base model all at once, you can&rsquo;t tell which one made the difference.</li>
<li><strong>Pin versions and keep monitoring</strong>: recalibrate the temperatures and thresholds whenever the base model or the data changes; after going live, keep logging the probability distributions and spot-check answers, and go back to the start of the flywheel as soon as the distribution drifts.</li>
</ol>
<h2 id="conclusion">Conclusion</h2>
<p>The whole article in a few points:</p>
<ol>
<li><strong>Approach</strong>: for a zero-shot generalist like Jev, take the &ldquo;decoder + option letters&rdquo; route and start with Qwen3.5-4B + LoRA; if you only serve fixed tasks and need maximum speed, the encoder route still pays off.</li>
<li><strong>Objective</strong>: train with a strictly proper scoring rule, then calibrate with temperature scaling. RLCD is one way to implement this, not magic.</li>
<li><strong>Data</strong>: task diversity, label quality, strict decontamination, and splitting by task matter far more than the model architecture.</li>
<li><strong>Compute</strong>: one LoRA run on a 4B model takes a few hours and around $15 on a single H100; the money goes into round after round of iteration.</li>
<li><strong>Evaluation</strong>: beyond accuracy, look at calibration, position bias, threshold curves, and latency; benchmark against JevBench&rsquo;s public items and sign off with your own lockbox.</li>
</ol>
<p>Jev defined the &ldquo;decision model&rdquo; category, and in just one month the open-source community has produced more than a hundred alternatives, the best of which are already on par with Jev on the leaderboard. Training your own decision model has gone from &ldquo;can it be done?&rdquo; to &ldquo;is it worth doing?&rdquo; If you have your own data, compliance requirements, or a particular sensitivity to latency and cost, the answer is most likely yes.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://benchmarkheaven.com/jev-models">JevBench: Open-weights decision model leaderboard</a></li>
<li><a href="https://benchmarkheaven.com/jev-models/methodology">JevBench: Methodology</a></li>
<li><a href="https://github.com/fstandhartinger/jevbench">JevBench: Evaluation harness and public items (GitHub)</a></li>
<li><a href="https://huggingface.co/h2oai/h2o-lightning-4b">H2O-Lightning-4B model card</a></li>
<li><a href="https://github.com/NandhaKishorM/laya">Laya GitHub repository</a></li>
<li><a href="https://dev.to/nandakishor_m_6cc0adfde9f/i-built-non-autoregressive-decision-models-a-year-ago-then-a-frontier-lab-called-it-a-18me">Nandakishor M: I Built Non-Autoregressive Decision Models a Year Ago. Then a Frontier Lab Called It a &ldquo;Breakthrough&rdquo;.</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3.5-4B">Qwen3.5-4B model card</a></li>
<li><a href="https://doi.org/10.1198/016214506000001437">Gneiting &amp; Raftery: Strictly Proper Scoring Rules, Prediction, and Estimation (2007)</a></li>
<li><a href="https://arxiv.org/abs/1706.04599">Guo et al.: On Calibration of Modern Neural Networks (2017)</a></li>
<li><a href="https://arxiv.org/abs/2207.05221">Kadavath et al.: Language Models (Mostly) Know What They Know (2022)</a></li>
<li><a href="https://arxiv.org/abs/2309.03882">Zheng et al.: Large Language Models Are Not Robust Multiple Choice Selectors (2023)</a></li>
<li><a href="https://arxiv.org/abs/2109.01652">Wei et al.: Finetuned Language Models Are Zero-Shot Learners (2021)</a></li>
<li><a href="https://arxiv.org/abs/2106.09685">Hu et al.: LoRA: Low-Rank Adaptation of Large Language Models (2021)</a></li>
<li><a href="/blog/2026/jev-vs-laya/">Jev vs Laya: Comparing and Choosing Between Closed-Source and Open-Source System One Decision Models</a></li>
<li><a href="/blog/2026/microsoft-decision-1/">Microsoft-Decision-1 Explained: Microsoft Joins the Decision Model Race, Compared with Jev and Laya</a></li>
<li><a href="/blog/2025/llm-fine-tuning/">A Comprehensive Guide to LLM Fine-Tuning: Methods, Comparisons, and Best-Fit Scenarios</a></li>
<li><a href="/blog/2026/ppo-vs-grpo/">PPO vs GRPO: Comparing and Choosing Between Two Mainstream LLM Reinforcement Learning Paradigms</a></li>
</ul>
]]></content:encoded></item></channel></rss>