<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Notes · Alvin Alias</title>
<link>https://alvinalias.com/notes/</link>
<atom:link href="https://alvinalias.com/notes/index.xml" rel="self" type="application/rss+xml"/>
<description>Working notes from building data science systems, mistakes included.</description>
<generator>quarto-1.8.25</generator>
<lastBuildDate>Fri, 10 Jul 2026 07:00:00 GMT</lastBuildDate>
<item>
  <title>I stopped asking an LLM to write every RAG answer</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/not-every-rag-query-needs-an-llm.html</link>
  <description><![CDATA[ 




<p>“What is the threshold quantity for chlorine?” and “Why is phosgene’s threshold lower than ammonia’s?” mention the same kind of fact. They should not use the same answer path.</p>
<p>The first question has one controlled value in an OSHA table. Letting a language model rewrite it adds a failure mode. The second question asks for an explanation, so retrieval and generation are useful. My first RAG build sent both through retrieval and an LLM. V2 classifies the question first.</p>
<section id="three-routes" class="level2">
<h2 class="anchored" data-anchor-id="three-routes">Three routes</h2>
<p>The router chooses <code>lookup</code>, <code>interpret</code>, or <code>clarify</code> before retrieval.</p>
<table class="caption-top table">
<colgroup>
<col style="width: 50%">
<col style="width: 50%">
</colgroup>
<thead>
<tr class="header">
<th>Intent</th>
<th>What the system does</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><code>lookup</code></td>
<td>Reads a verified value, unit, source quote, and page from JSON. No model generates the answer.</td>
</tr>
<tr class="even">
<td><code>interpret</code></td>
<td>Embeds the query, retrieves passages, asks a model for a cited answer, then validates the citations and numbers.</td>
</tr>
<tr class="odd">
<td><code>clarify</code></td>
<td>Lists the competing stored meanings and asks the user to choose.</td>
</tr>
</tbody>
</table>
<p>“What is the TQ for chlorine?” now returns 1,500 pounds from OSHA page 46 through a Python template. “What is the TQ for hydrogen?” matches several hydrogen compounds and asks which one the user means. “Explain the difference between a relief valve and a safety valve” keeps the full retrieval and generation path.</p>
<p>The failure direction stays simple. If the classifier fails, deterministic rules take over. If a fact lookup misses, the query falls through to retrieval. The system returns to the measured V1 behavior instead of dropping the question.</p>
</section>
<section id="the-facts-file-took-more-work-than-the-router" class="level2">
<h2 class="anchored" data-anchor-id="the-facts-file-took-more-work-than-the-router">The facts file took more work than the router</h2>
<p>The lookup path uses 88 verified facts from two documents: 41 from the 2017 DOE central air-conditioner and heat-pump rule, and 47 from OSHA 3132. Each record carries the value, unit, page, source wording, and search terms.</p>
<p>The source documents resisted clean extraction. Federal Register tables use a multi-column layout that scrambles the PDF text layer. OSHA prints “Hodrogen Cyanide” and shows the Hydrogen Sulfide CAS as <code>7783=06-4</code>. I kept those strings in the evidence quotes and normalized the structured fields used for lookup.</p>
<p>The frozen factual evaluation has 50 questions. The lookup returns the correct value and page on 50 of 50. That result is narrow by design. It says the deterministic path passes this test set. New phrasing can still miss and fall through to synthesis.</p>
</section>
<section id="i-benchmarked-the-router-instead-of-picking-the-fanciest-option" class="level2">
<h2 class="anchored" data-anchor-id="i-benchmarked-the-router-instead-of-picking-the-fanciest-option">I benchmarked the router instead of picking the fanciest option</h2>
<p>The intent set contains 168 reviewed questions with a fixed 135/33 train and holdout split. I ran four backends on the same holdout.</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th>Backend</th>
<th style="text-align: right;">Correct</th>
<th style="text-align: right;">Wilson 95% interval</th>
<th style="text-align: right;">Median latency</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Rules</td>
<td style="text-align: right;">21/33</td>
<td style="text-align: right;">46.6% to 77.8%</td>
<td style="text-align: right;">0 ms</td>
</tr>
<tr class="even">
<td>Groq zero-shot</td>
<td style="text-align: right;">29/33</td>
<td style="text-align: right;">72.7% to 95.2%</td>
<td style="text-align: right;">330 ms</td>
</tr>
<tr class="odd">
<td>GPT-4o-mini zero-shot</td>
<td style="text-align: right;">31/33</td>
<td style="text-align: right;">80.4% to 98.3%</td>
<td style="text-align: right;">571 ms</td>
</tr>
<tr class="even">
<td>DistilBERT + LoRA</td>
<td style="text-align: right;">27/33</td>
<td style="text-align: right;">65.6% to 91.4%</td>
<td style="text-align: right;">21 ms</td>
</tr>
</tbody>
</table>
<p>GPT-4o-mini had the highest point estimate. The difference from Groq was two questions, and their confidence intervals overlap. That test does not establish a reliable quality advantage. I kept Groq in production because its free plan avoids a classifier charge and it was faster in this run. The local LoRA model remains an offline option.</p>
</section>
<section id="which-api-gets-called" class="level2">
<h2 class="anchored" data-anchor-id="which-api-gets-called">Which API gets called</h2>
<p>The production split is easy to lose track of because Groq and OpenAI do different jobs.</p>
<table class="caption-top table">
<colgroup>
<col style="width: 33%">
<col style="width: 33%">
<col style="width: 33%">
</colgroup>
<thead>
<tr class="header">
<th>Query path</th>
<th>Groq</th>
<th>OpenAI</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Verified fact lookup</td>
<td>Intent classification</td>
<td>No call</td>
</tr>
<tr class="even">
<td>Clarification</td>
<td>Intent classification</td>
<td>No call</td>
</tr>
<tr class="odd">
<td>Synthesized answer</td>
<td>Intent classification and answer generation</td>
<td>Query embedding</td>
</tr>
<tr class="even">
<td>Health check</td>
<td>No call</td>
<td>No call</td>
</tr>
</tbody>
</table>
<p>OpenAI remains on the synthesized path because the committed Chroma index was built with <code>text-embedding-3-small</code>. A query has to use the same vector space as the stored documents. The current price is <a href="https://developers.openai.com/api/docs/models/text-embedding-3-small">$0.02 per million input tokens</a>, so the remaining paid call is small and only occurs when retrieval runs.</p>
<p>Groq serves <code>openai/gpt-oss-20b</code> for classification and generation. The <code>openai/</code> prefix is the model ID, not the API provider. Requests go to Groq, use the Groq key, and follow the <a href="https://console.groq.com/docs/rate-limits">Groq free-plan limits</a>.</p>
<p>GPT-4o-mini was called during the classifier benchmark. It is not the selected production classifier or generator.</p>
</section>
<section id="checking-the-generated-path" class="level2">
<h2 class="anchored" data-anchor-id="checking-the-generated-path">Checking the generated path</h2>
<p>Interpretation still needs an LLM, so V2 checks the answer after generation.</p>
<p>The validator confirms that each cited document and page appeared in the retrieved chunks. It checks that every number in the answer appears in those chunks. It also measures token overlap between each answer sentence and the retrieved text.</p>
<p>The first two checks catch fabricated citations and unsupported numbers. The sentence check is coarser. A fluent paraphrase can pass token overlap without being fully supported, and a correct sentence with different wording can be flagged. I keep the offline Ragas faithfulness score for the finer evaluation and use the runtime validator as a fast guardrail.</p>
</section>
<section id="what-changed-in-the-project" class="level2">
<h2 class="anchored" data-anchor-id="what-changed-in-the-project">What changed in the project</h2>
<p>V2 added more evaluation work than model work: 88 source-checked facts, 168 reviewed intent labels, four classifier benchmarks, 50 deterministic factual tests, 56 automated tests, and three local API smoke tests covering lookup, synthesis, and clarification.</p>
<p>The live interface shows which route produced each response. A recruiter can see whether a value came from the verified facts file or from cited synthesis, then open the retrieved passages beside it.</p>
<p>Try it at <a href="https://rag.alvinalias.com">rag.alvinalias.com</a>. The code, facts schema, intent set, and benchmark outputs are in the <a href="https://github.com/aalias01/rag-engineering-assistant">repository</a>.</p>
</section>
<section id="related-reading" class="level2">
<h2 class="anchored" data-anchor-id="related-reading">Related reading</h2>
<ul>
<li><a href="../posts/grading-a-rag.html">How I graded a RAG system before trusting it</a>, the V1 evaluation design, refusal traps, and retrieval ablations.</li>
<li><a href="https://github.com/aalias01/rag-engineering-assistant/blob/main/docs/SYSTEM_CARD.md">The RAG Engineering Assistant system card</a>, the current routes, limits, and provider behavior.</li>
</ul>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {I Stopped Asking an {LLM} to Write Every {RAG} Answer},
  date = {2026-07-10},
  url = {https://alvinalias.com/notes/posts/not-every-rag-query-needs-an-llm.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“I Stopped Asking an LLM to Write Every RAG
Answer.”</span> July 10, 2026. <a href="https://alvinalias.com/notes/posts/not-every-rag-query-needs-an-llm.html">https://alvinalias.com/notes/posts/not-every-rag-query-needs-an-llm.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>rag</category>
  <category>evaluation</category>
  <category>routing</category>
  <guid>https://alvinalias.com/notes/posts/not-every-rag-query-needs-an-llm.html</guid>
  <pubDate>Fri, 10 Jul 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>Why 0.775 is the right threshold when a miss costs $50K</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/threshold-economics.html</link>
  <description><![CDATA[ 




<p>Every classifier ships with a default decision threshold of 0.5, and on industrial failure data that default is quietly making an economic decision nobody signed off on: it prices a missed machine failure and an unnecessary maintenance call as equal mistakes. In any plant I ever worked in, they are not within an order of magnitude of equal. This entry is about moving one number, and why that move is the most defensible modeling decision in the project.</p>
<section id="the-accuracy-decoy" class="level2">
<h2 class="anchored" data-anchor-id="the-accuracy-decoy">The accuracy decoy</h2>
<p>The dataset (AI4I 2020, ten thousand machine records) has failures in 3.4% of rows. A model that predicts “no failure” every single time scores 96.6% accuracy and catches nothing. So accuracy is not reported anywhere in this project, and any imbalanced-classification result quoted without that disclaimer deserves a raised eyebrow.</p>
<p>The metrics that survive imbalance: PR-AUC (0.841 for the XGBoost model, against 0.820 random forest and 0.455 logistic baselines) and recall at whatever operating point you choose. The operating point is the story.</p>
</section>
<section id="charging-each-error-its-price" class="level2">
<h2 class="anchored" data-anchor-id="charging-each-error-its-price">Charging each error its price</h2>
<p>Model the two mistakes in money. A missed failure becomes unplanned downtime: modeled here at $50,000. A false alarm becomes a scheduled maintenance call that wasn’t needed: $2,000. Both numbers are stated assumptions, not measurements, and everything downstream inherits them, which is why they sit in the open on the demo page.</p>
<p>Then sweep the threshold and charge every mistake at every setting:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> threshold <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> np.arange(<span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.1</span>, <span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.9</span>, <span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.05</span>):</span>
<span id="cb1-2">    preds <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> (failure_proba <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;=</span> threshold).astype(<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">int</span>)</span>
<span id="cb1-3">    fn_cost <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> ((preds <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&amp;</span> (y_test <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>)).<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">sum</span>() <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">50_000</span></span>
<span id="cb1-4">    fp_cost <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> ((preds <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&amp;</span> (y_test <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>)).<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">sum</span>() <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2_000</span></span>
<span id="cb1-5">    total_cost <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> fn_cost <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> fp_cost</span></code></pre></div></div>
<p>The minimum lands at 0.775. At that threshold, on the held-out test set: 62 of 68 real failures caught (91.2% recall), 6 missed, 83 false alarms tolerated, $466K modeled total cost. Compare the intuition to the default: because a miss costs 25 times a false alarm, you might expect the threshold to drop below 0.5 to catch more failures. It moves the other way, up to 0.775, because this model’s probabilities are well separated: the failures it can catch, it catches with high confidence, so raising the bar sheds expensive false alarms while giving up almost no real catches. That non-obvious direction is exactly why you sweep instead of reasoning by vibes.</p>
</section>
<section id="the-part-that-generalizes" class="level2">
<h2 class="anchored" data-anchor-id="the-part-that-generalizes">The part that generalizes</h2>
<p>The threshold is not a modeling constant; it is a business input wearing a decimal point. Change the cost assumptions and the optimum moves. Double the false-alarm cost (a plant where techs are scarce) and the optimum climbs higher; make misses catastrophic (safety-critical equipment) and it slides down. The model did not change. The economics did.</p>
<p>That is also why the live demo now lets you move the costs yourself: the page ships the real per-threshold error counts from the same test set the published numbers come from, and recomputes the cost curve and its minimum in front of you. The one non-negotiable behind that feature: the exported counts had to reproduce the published confusion matrix at 0.775 exactly, 6 and 83, or the export fails the build. Re-derivations must agree with published numbers before they earn trust.</p>
</section>
<section id="owning-the-misses" class="level2">
<h2 class="anchored" data-anchor-id="owning-the-misses">Owning the misses</h2>
<p>91.2% recall means about 9 in 100 failures slip past. The demo draws real records from the dataset with their outcomes hidden, predicts, then reveals what actually happened, and when a run lands on a miss, the page says so in plain text. A maintenance manager deciding whether to trust a model needs to know what its misses look like more than they need another decimal of AUC.</p>
<p>Try it at <a href="https://machine-failure.alvinalias.com">machine-failure.alvinalias.com</a>; the sweep, the notebooks, and the model card are in the <a href="https://github.com/aalias01/industrial-failure-classification">repo</a>.</p>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {Why 0.775 Is the Right Threshold When a Miss Costs {\$50K}},
  date = {2026-06-23},
  url = {https://alvinalias.com/notes/posts/threshold-economics.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“Why 0.775 Is the Right Threshold When a Miss
Costs $50K.”</span> June 23, 2026. <a href="https://alvinalias.com/notes/posts/threshold-economics.html">https://alvinalias.com/notes/posts/threshold-economics.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>classification</category>
  <category>maintenance</category>
  <guid>https://alvinalias.com/notes/posts/threshold-economics.html</guid>
  <pubDate>Tue, 23 Jun 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>Regex confidence is a poor escalation signal</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/regex-confidence-gate.html</link>
  <description><![CDATA[ 




<p>Industrial companies sit on years of maintenance work orders: free text written by technicians about what broke and what they did. Extracting structured fields from that text (equipment tag, failure mode, parts, root cause) is a classic job for an LLM, and a classic place to worry about cost. The obvious architecture is a hybrid: run a cheap regex extractor first, escalate to the LLM only when the regex reports low confidence. I built exactly that, and its most useful output was a negative result.</p>
<section id="the-setup" class="level2">
<h2 class="anchored" data-anchor-id="the-setup">The setup</h2>
<p>The corpus is 3,000 synthetic work orders with a calibrated noise layer: typos, technician shorthand like “repl” and “brg” and “RTS,” vague observations, and terse one-liners, because clean synthetic text makes every extractor score a meaningless 100%. The taxonomy and vocabulary come from twelve years of writing real work orders across HVAC, subsea, and manufacturing. Ground truth is nulled per-field wherever the noise layer removed a fact from the text, so no extractor gets penalized for information that is not there.</p>
<p>Three extractors ran on the same 100-record evaluation sample: rule-based regex (free, under a millisecond), LLM-only (GPT-4o-mini with a Pydantic schema, about 1.1 seconds per record), and the hybrid, which escalated to the LLM whenever the regex’s self-reported confidence fell below 0.7.</p>
</section>
<section id="the-results-including-the-embarrassing-column" class="level2">
<h2 class="anchored" data-anchor-id="the-results-including-the-embarrassing-column">The results, including the embarrassing column</h2>
<p>On equipment tags, regex nearly matches the LLM (82% against 99%), because tags follow an XX-NNN pattern that regex was born for. On failure mode, the narrative field, regex collapses to 13% while the LLM reaches 70%: typos, shorthand, and paraphrase defeat keyword matching.</p>
<p>The hybrid cut LLM calls by 56%. If the gate were working, accuracy should sit most of the way toward the LLM’s numbers. On failure mode it landed at 23%: barely above regex, nowhere near 70%.</p>
</section>
<section id="the-autopsy" class="level2">
<h2 class="anchored" data-anchor-id="the-autopsy">The autopsy</h2>
<p>The gate assumed regex confidence correlates with regex correctness. It does not, and the direction of the failure is the interesting part: on clean, well-structured text the regex is confident and right, and on noisy text it is confident and wrong, because a keyword pattern that happens to match garbage reports the same confidence as one matching the real thing. The records that most needed escalation were exactly the ones that never got it.</p>
<p>That is a calibration failure, and it generalizes far beyond regex. Any pipeline that routes work based on a component’s self-reported confidence inherits that component’s calibration, and cheap extractors are almost never calibrated on the distribution that matters, which is the messy tail.</p>
</section>
<section id="what-a-production-version-needs" class="level2">
<h2 class="anchored" data-anchor-id="what-a-production-version-needs">What a production version needs</h2>
<p>An escalation signal that does not come from the extractor grading its own homework. The candidates I would try, in order: field-completeness checks (empty or suspiciously short extracted fields are a cheap, honest tell), a small classifier trained to predict extraction failure from surface features of the text, or spot-check sampling with drift monitoring. The cost at stake is real but small: LLM-only extraction runs roughly $0.10 to $0.15 per thousand records at GPT-4o-mini prices, so the hybrid has to be nearly free to be worth its complexity.</p>
<p>Negative results like this one rarely make it into portfolios, which is exactly why this one is in mine: the measurement worked, the architecture failed, and the failure has a nameable cause with a testable fix.</p>
</section>
<section id="see-the-pipeline" class="level2">
<h2 class="anchored" data-anchor-id="see-the-pipeline">See the pipeline</h2>
<p>The classifier this corpus trains (DistilBERT with LoRA, 94.4% F1 while updating 1.1% of parameters) and the similar-case retrieval run live at <a href="https://workorders.alvinalias.com">workorders.alvinalias.com</a>; the extraction study, with the per-field table and the eval script, is in the <a href="https://github.com/aalias01/maintenance-work-order-nlp">repo</a>.</p>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {Regex Confidence Is a Poor Escalation Signal},
  date = {2026-06-19},
  url = {https://alvinalias.com/notes/posts/regex-confidence-gate.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“Regex Confidence Is a Poor Escalation
Signal.”</span> June 19, 2026. <a href="https://alvinalias.com/notes/posts/regex-confidence-gate.html">https://alvinalias.com/notes/posts/regex-confidence-gate.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>nlp</category>
  <category>evaluation</category>
  <guid>https://alvinalias.com/notes/posts/regex-confidence-gate.html</guid>
  <pubDate>Fri, 19 Jun 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>Scoring equipment health when nobody labeled the failures</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/health-without-labels.html</link>
  <description><![CDATA[ 




<p>The most common real-world condition in industrial analytics is also the one textbooks skip: you have years of sensor data and no failure labels. Nobody wrote down when the compressor died, or the log that exists says “replaced unit” with no date you can trust. I spent three years in HVAC product development at Rheem, so when I built a health-scoring system on 2.88 million hourly readings from 497 buildings, I refused the usual shortcut of pretending threshold breaches are labels. This entry is what you can honestly do instead.</p>
<section id="features-from-physics-not-from-a-menu" class="level2">
<h2 class="anchored" data-anchor-id="features-from-physics-not-from-a-menu">Features from physics, not from a menu</h2>
<p>The feature set came from what I watched machines actually do, not from a time-series feature catalog: COP (cooling delivered per watt drawn, the best single efficiency signal in refrigeration), supply-side delta-T (narrows as coils foul), refrigerant-circuit delta-T (widens as charge depletes), load ratio (high load plus falling COP is the zone where things break), and rolling statistics of those over 24 hours and 7 days.</p>
<p>An Isolation Forest scores each reading for anomaly structure; the score inverts and rescales to a 0 to 100 health number. Standard so far. The two decisions worth writing down are what made it defensible.</p>
</section>
<section id="decision-one-judge-each-unit-against-its-own-baseline" class="level2">
<h2 class="anchored" data-anchor-id="decision-one-judge-each-unit-against-its-own-baseline">Decision one: judge each unit against its own baseline</h2>
<p>A raw anomaly score answers “is this reading strange for the fleet?” That is the wrong question. Some units always ran hot; some buildings always ran heavy loads. At Rheem you never judged a compressor against the fleet’s vibration baseline, only against its own commissioning signature.</p>
<p>So the score is normalized per unit: each building’s raw scores are mapped through that building’s own 5th and 95th percentiles, so health 20 means “bad for this unit,” not “different from other units.” The units with unusual but stable operating profiles stopped false-alarming the moment this landed.</p>
</section>
<section id="decision-two-keep-a-second-detector-as-witness" class="level2">
<h2 class="anchored" data-anchor-id="decision-two-keep-a-second-detector-as-witness">Decision two: keep a second detector as witness</h2>
<p>With no labels, there is no accuracy number, and anyone who quotes one should be asked where it came from. What you can do is triangulate. A Local Outlier Factor model, which defines anomaly by local density rather than isolation depth, scores the same readings independently. The two detectors agree on 91.3% of a 100,000-reading sample.</p>
<p>Agreement is not correctness. What it establishes is that the anomaly structure mostly does not depend on which definition of “strange” you pick, and the 8.7% where they disagree is exactly the set where the call is genuinely ambiguous. The contamination setting got the same treatment: run at 0.02, 0.05, and 0.10, and check that everything flagged at the strict setting stays flagged at the middle one. It does, which means the flagged population is not an artifact of one knob.</p>
</section>
<section id="the-finding-i-didnt-expect" class="level2">
<h2 class="anchored" data-anchor-id="the-finding-i-didnt-expect">The finding I didn’t expect</h2>
<p>The strongest SHAP contributor across the fleet is not COP level. It is the 24-hour rolling standard deviation of COP: volatility, not value. In hindsight it is obvious from the shop floor: a machine that is failing intermittently swings before it sags. A compressor short-cycling, a valve hunting, a sensor going flaky: they all show up as variance first. The model found the intermittent-fault signature on its own, and that, more than any metric, is what made me trust the anomaly structure.</p>
</section>
<section id="what-this-can-and-cannot-say" class="level2">
<h2 class="anchored" data-anchor-id="what-this-can-and-cannot-say">What this can and cannot say</h2>
<p>It cannot say “this unit will fail in N days”; that needs labels or run-to-failure history (my turbofan project is that problem, with NASA’s labels). It can rank 497 units by how far each has drifted from its own normal, with a second detector countersigning most calls, and that ranking is what a maintenance planner actually needs on Monday morning: which ten units get a technician this week.</p>
<p>The live fleet board at <a href="https://hvac.alvinalias.com">hvac.alvinalias.com</a> shows the real scored units and prints the detector cross-check with every score. The repo, with the sensitivity checks and the normalization code, is at <a href="https://github.com/aalias01/hvac-equipment-health">github.com/aalias01/hvac-equipment-health</a>.</p>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {Scoring Equipment Health When Nobody Labeled the Failures},
  date = {2026-06-16},
  url = {https://alvinalias.com/notes/posts/health-without-labels.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“Scoring Equipment Health When Nobody Labeled
the Failures.”</span> June 16, 2026. <a href="https://alvinalias.com/notes/posts/health-without-labels.html">https://alvinalias.com/notes/posts/health-without-labels.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>anomaly-detection</category>
  <category>industrial</category>
  <guid>https://alvinalias.com/notes/posts/health-without-labels.html</guid>
  <pubDate>Tue, 16 Jun 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>How I graded a RAG system before trusting it</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/grading-a-rag.html</link>
  <description><![CDATA[ 




<p>A RAG system will answer almost anything you ask it, fluently, with citations that look right. That is precisely the problem. Before putting mine in front of anyone, I wanted a number for how often the citations actually point at the truth, and a separate number for how often the prose respects the citations. This entry is the grading system, because the grading turned out to be more work, and more interesting, than the pipeline.</p>
<section id="the-system-in-one-paragraph" class="level2">
<h2 class="anchored" data-anchor-id="the-system-in-one-paragraph">The system, in one paragraph</h2>
<p>Six public engineering documents (DOE handbooks on thermodynamics, heat transfer, and mechanical components, NASA’s systems engineering handbook, OSHA’s process safety booklet, and one federal efficiency rule I happen to have complied with in a past job at Rheem): 778 pages, split into 2,091 chunks of 300 tokens, retrieved with dense embeddings and BM25 fused by reciprocal rank, optionally reranked by a cross-encoder, and answered by an LLM that is instructed to use only the retrieved excerpts and to cite document and page for everything.</p>
</section>
<section id="one-exam-two-report-cards" class="level2">
<h2 class="anchored" data-anchor-id="one-exam-two-report-cards">One exam, two report cards</h2>
<p>The eval set is 31 hand-labeled questions, each with the document and pages that contain the answer. Building it was tedious and it is the most valuable artifact in the repo.</p>
<p>Retrieval gets graded against the labeled pages: did the right chunk show up in the top 3 (Recall@3 came out 0.923), and how high did it rank (MRR 0.817). Generation gets graded against the retrieved text, not against the world: does every claim in the answer trace to the excerpts (faithfulness 0.928), and does the answer address the question (relevancy 0.960).</p>
<p>The reason for two report cards is that each one can fail alone. A fluent, faithful answer built on the wrong chunks is confidently misgrounded, and retrieval metrics catch it. Perfect chunks summarized with invented numbers is hallucination, and faithfulness catches it. A single blended score would have hidden every failure I actually found while tuning, including the chunk size: 300 tokens won an ablation against larger sizes, and I would not have seen why without the retrieval-side metrics standing alone.</p>
</section>
<section id="the-refusal-traps" class="level2">
<h2 class="anchored" data-anchor-id="the-refusal-traps">The refusal traps</h2>
<p>Five of the 31 questions are traps: engineering questions whose answers are not in the corpus, sitting close enough to the domain to be tempting. The correct behavior is declining, and the system prompt instructs the exact sentence to use. The system declined 5 of 5. There are also five borderline questions living in the gray zone between answerable and not, because a system that only ever sees clean cases is a system you know nothing about.</p>
<p>Refusal-as-a-feature changes how you demo. Most RAG demos hope you ask something the corpus covers. This one includes questions where the right answer is “I don’t have information on that in the provided documents,” and shows the system saying so.</p>
</section>
<section id="what-the-honest-numbers-cost" class="level2">
<h2 class="anchored" data-anchor-id="what-the-honest-numbers-cost">What the honest numbers cost</h2>
<p>I expected hybrid retrieval to win because engineers ask about exact strings such as acronyms, standard numbers, and valve designations. The result went the other way. Dense-only retrieval reached 0.923 Recall@3 and 0.817 MRR. Hybrid RRF reached 0.885 and 0.774. Adding the cross-encoder recovered Recall@3 to 0.923 but lowered MRR to 0.741 and tripled latency. The API keeps both controls for comparison; the free deployment leaves the reranker off.</p>
<p>Every run prints latency and generation cost. Production uses Groq’s free plan for generation and OpenAI only for retrieval embeddings, so a synthesized query retains one small paid embedding call.</p>
<p>The limitation I would state before anyone asks: 31 queries is a small exam. It is big enough to expose retrieval regressions and refusal failures during development, and too small to certify anything. Growing it is the roadmap, and every number above says “on 31 labeled queries” wherever it appears.</p>
</section>
<section id="ask-it-a-graded-question-yourself" class="level2">
<h2 class="anchored" data-anchor-id="ask-it-a-graded-question-yourself">Ask it a graded question yourself</h2>
<p>The live demo at <a href="https://rag.alvinalias.com">rag.alvinalias.com</a> has a button that draws from the same graded exam: it asks the question, then prints the labeled source next to whatever the answer cited, hit or miss, and one rotation in the set is a trap where the correct outcome is a refusal. The eval set, protocol, and pipeline are in the <a href="https://github.com/aalias01/rag-engineering-assistant">repo</a>.</p>
</section>
<section id="related-reading" class="level2">
<h2 class="anchored" data-anchor-id="related-reading">Related reading</h2>
<ul>
<li><a href="../posts/not-every-rag-query-needs-an-llm.html">I stopped asking an LLM to write every RAG answer</a>, the V2 routing, verified facts path, classifier benchmark, and provider call map.</li>
</ul>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {How {I} Graded a {RAG} System Before Trusting It},
  date = {2026-06-12},
  url = {https://alvinalias.com/notes/posts/grading-a-rag.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“How I Graded a RAG System Before Trusting
It.”</span> June 12, 2026. <a href="https://alvinalias.com/notes/posts/grading-a-rag.html">https://alvinalias.com/notes/posts/grading-a-rag.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>rag</category>
  <category>evaluation</category>
  <guid>https://alvinalias.com/notes/posts/grading-a-rag.html</guid>
  <pubDate>Fri, 12 Jun 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>Point-in-time features for transaction data</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/point-in-time-features.html</link>
  <description><![CDATA[ 




<div class="last-verified-line">
last verified 2026-07
</div>
<p>Every feature about a customer’s history answers a question with a hidden clock in it. “What is this customer’s return rate?” is not one question; it is a different question at every moment in their life. Compute it over their whole history and you have handed your model information from the future. This reference is the discipline I use to keep the clock honest, learned building a return-risk model on 1,067,371 real UK retail transactions, where getting it wrong inflates every metric you report.</p>
<section id="the-rule" class="level2">
<h2 class="anchored" data-anchor-id="the-rule">The rule</h2>
<p>When you score a transaction, every history feature must be computed from rows strictly before that transaction. Lifetime return rate, return velocity, average days to return, order counts, tenure: all of them get a cutoff at the moment being scored, none of them may see the row itself or anything after it.</p>
<p>The implementation shape that makes this cheap: sort by customer and timestamp, then build history features with expanding or windowed aggregations that shift by one row, so each transaction sees only its own past. In pandas that is a <code>groupby</code> plus <code>shift</code> before any expanding aggregation; in SQL it is a window frame ending at <code>1 PRECEDING</code>. The moment you see a history feature built without a shift, assume leakage until proven otherwise.</p>
</section>
<section id="why-the-split-has-to-be-temporal-too" class="level2">
<h2 class="anchored" data-anchor-id="why-the-split-has-to-be-temporal-too">Why the split has to be temporal too</h2>
<p>Point-in-time features fix leakage inside rows; the split fixes it between training and evaluation. A random split on transaction data puts January’s rows in training and December’s siblings in test, for the same customers, in both directions of time. The model partly memorizes customers instead of learning behavior, and the test score inflates.</p>
<p>My split: train on December 2009 through June 2011, test on July through December 2011. The model never sees the future of any customer it is judged on. The classifier holds 0.992 ROC-AUC under that discipline, which is exactly why the number is worth quoting; the same architecture under a random split would report something flattering and useless.</p>
</section>
<section id="the-self-check-a-rolling-backtest" class="level2">
<h2 class="anchored" data-anchor-id="the-self-check-a-rolling-backtest">The self-check: a rolling backtest</h2>
<p>One temporal split is one snapshot. To know whether performance is stable rather than lucky, re-fit and score across rolling monthly windows: train up to month k, score month k+1, slide, repeat. Eighteen windows on this dataset gave a mean top-decile precision of 0.181, and, just as important, a spread you can look at. If a single test-set number is much better than your rolling mean, the snapshot is flattering you.</p>
</section>
<section id="the-checklist" class="level2">
<h2 class="anchored" data-anchor-id="the-checklist">The checklist</h2>
<ol type="1">
<li>Every history feature has an explicit as-of cutoff, enforced by a shift or window frame, not by intention.</li>
<li>The train/test boundary is a date, not a random seed.</li>
<li>Customers do not appear on both sides of the boundary with the model expected to generalize to them as strangers, unless that is deliberately your setting; know which setting you are in.</li>
<li>A rolling backtest exists, and the headline metric survives it.</li>
<li>Anything the model consumes at serving time is something you could actually have known at that moment: if the feature table is rebuilt nightly, your features are as of last night, and evaluation should mimic that staleness.</li>
</ol>
<p>Item five is the one that separates paper pipelines from production ones, and it is the first question I would ask anyone presenting a transaction model.</p>
</section>
<section id="where-this-is-applied" class="level2">
<h2 class="anchored" data-anchor-id="where-this-is-applied">Where this is applied</h2>
<p>The live return-risk demo at <a href="https://returns.alvinalias.com">returns.alvinalias.com</a> scores curated real invoice lines with features built under exactly these rules. The visible ledger shows customer, invoice, stock code, quantity, price, and country. The API also receives the historical context that would have been known at that invoice moment, including the quantity and price z-scores, weekend flag, month-end proximity, and product return rate. The repo with the feature pipeline, temporal split, demo-case builder, and backtest notebook is at <a href="https://github.com/aalias01/retail-returns-intelligence">github.com/aalias01/retail-returns-intelligence</a>.</p>
</section>
<section id="changelog" class="level2">
<h2 class="anchored" data-anchor-id="changelog">Changelog</h2>
<ul>
<li>2026-07: updated the live-demo note after moving the frontend to curated real invoice samples.</li>
<li>2026-05: first published.</li>
</ul>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {Point-in-Time Features for Transaction Data},
  date = {2026-05-31},
  url = {https://alvinalias.com/notes/posts/point-in-time-features.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“Point-in-Time Features for Transaction
Data.”</span> May 31, 2026. <a href="https://alvinalias.com/notes/posts/point-in-time-features.html">https://alvinalias.com/notes/posts/point-in-time-features.html</a>.
</div></div></section></div> ]]></description>
  <category>reference</category>
  <category>feature-engineering</category>
  <category>evaluation</category>
  <guid>https://alvinalias.com/notes/posts/point-in-time-features.html</guid>
  <pubDate>Sun, 31 May 2026 07:00:00 GMT</pubDate>
</item>
<item>
  <title>How a 30-cycle rolling mean beat a deep LSTM at turbofan prognostics</title>
  <dc:creator>Alvin Alias</dc:creator>
  <link>https://alvinalias.com/notes/posts/rolling-mean-vs-lstm.html</link>
  <description><![CDATA[ 




<p>In 2017, a deep LSTM set a widely cited benchmark on NASA’s turbofan degradation dataset: 16.14 RMSE predicting how many flight cycles an engine has left. My XGBoost model reaches 15.85 on the same official test set with no neural network anywhere in the pipeline. This entry is about the two decisions that did the work, because neither of them is the model.</p>
<section id="the-problem-briefly" class="level2">
<h2 class="anchored" data-anchor-id="the-problem-briefly">The problem, briefly</h2>
<p>NASA’s C-MAPSS FD001 dataset simulates 100 turbofan engines running to failure, 20,631 training cycles in all, each cycle a row of 21 sensor readings: temperatures, pressures, shaft speeds, fuel flow. The task is remaining useful life: given an engine’s sensor history, predict how many cycles remain. You train on complete run-to-failure trajectories and predict on engines cut off at an arbitrary point, scored against the official held-back answers.</p>
<p>Seven of the 21 sensors barely move in FD001, so they carry nothing. That leaves 14 informative channels, and one convention worth knowing: remaining life is capped at 125 cycles, because a healthy engine’s sensors look identical whether it has 130 or 300 cycles left. Predicting “125 or more” is the honest ceiling.</p>
</section>
<section id="decision-one-split-by-engine-never-randomly" class="level2">
<h2 class="anchored" data-anchor-id="decision-one-split-by-engine-never-randomly">Decision one: split by engine, never randomly</h2>
<p>The most common way to get a flattering RUL number is a random train/validation split. Rows from the same engine land on both sides, the model memorizes each engine’s personal drift, and validation error collapses. It isn’t prediction, it’s recognition.</p>
<p>Every split in this project is grouped by engine: an engine’s entire trajectory is either training or validation, never both. The validation score got worse the day I did this, which is exactly the point. The number stopped lying.</p>
<p>If you take one thing from this entry: on any dataset where rows belong to units that degrade, group your splits by unit and expect the honest number to be uglier.</p>
</section>
<section id="decision-two-size-the-window-to-the-physics" class="level2">
<h2 class="anchored" data-anchor-id="decision-two-size-the-window-to-the-physics">Decision two: size the window to the physics</h2>
<p>The failure mode in FD001 is compressor fouling. It is slow. Cycle to cycle, the sensor drift is smaller than the noise; over tens of cycles, it is unmistakable. So the features are 30-cycle rolling means and standard deviations of each sensor, and 30 wasn’t tuned by grid search: it approximates one fouling cycle, the timescale on which the degradation physically accumulates.</p>
<p>This is the part I’d defend in any review: the window is where the domain knowledge lives. An LSTM is asked to discover that timescale from data. A rolling mean is told the timescale by someone who knows the machine. On a dataset this size, telling beats discovering:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th>Model</th>
<th>RMSE, FD001 official test</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>CNN (Babu et al., 2016)</td>
<td>18.45</td>
</tr>
<tr class="even">
<td>Ridge regression, my baseline</td>
<td>17.47</td>
</tr>
<tr class="odd">
<td>Deep LSTM (Zheng et al., 2017)</td>
<td>16.14</td>
</tr>
<tr class="even">
<td>XGBoost on rolling features, this project</td>
<td>15.85</td>
</tr>
</tbody>
</table>
<p>Worth saying plainly: transformer and hybrid models published since have pushed FD001 below all of these. 15.85 is a strong classical result, not the state of the art. What makes it interesting is the ratio of result to machinery.</p>
</section>
<section id="the-model-agrees-with-the-thermodynamics" class="level2">
<h2 class="anchored" data-anchor-id="the-model-agrees-with-the-thermodynamics">The model agrees with the thermodynamics</h2>
<p>The top SHAP feature for most predictions is the 30-cycle mean of sensor 3, the HPC outlet temperature. That is the textbook fouling signature: as the compressor fouls, efficiency drops and outlet temperature climbs. The next features in the ranking are the upstream and downstream sensors that respond to the same fault. Nobody told the model any thermodynamics; checking that its explanations recover the known physics is the closest thing tabular ML has to a sanity proof.</p>
</section>
<section id="what-id-redo" class="level2">
<h2 class="anchored" data-anchor-id="what-id-redo">What I’d redo</h2>
<p>The prediction ships with a ±15 cycle band, and that band is a heuristic, labeled as such in the API schema itself. It is not a calibrated interval, and pretending otherwise would be worse than the crude band. The fix is known (conformal prediction or quantile regression) and it is the top of the V2 list, along with extending past FD001’s single operating condition, where this feature set would need regime clustering before any rolling statistics make sense.</p>
</section>
<section id="try-it-against-the-official-answers" class="level2">
<h2 class="anchored" data-anchor-id="try-it-against-the-official-answers">Try it against the official answers</h2>
<p>The live demo at <a href="https://turbofan.alvinalias.com">turbofan.alvinalias.com</a> does something I wish more ML demos did: one click loads a real engine from NASA’s held-back test set, the model commits to an estimate, and then the official answer prints next to it, error included. Some runs miss. That’s what 15.85 RMSE means, and watching it miss honestly tells you more about the model than any metric table. Code, notebooks, and the model card are in the <a href="https://github.com/aalias01/cmapss-rul-prediction">repo</a>.</p>


</section>

<div id="quarto-appendix" class="default"><section class="quarto-appendix-contents" id="quarto-citation"><h2 class="anchored quarto-appendix-heading">Citation</h2><div><div class="quarto-appendix-secondary-label">BibTeX citation:</div><pre class="sourceCode code-with-copy quarto-appendix-bibtex"><code class="sourceCode bibtex">@online{alias2026,
  author = {Alias, Alvin},
  title = {How a 30-Cycle Rolling Mean Beat a Deep {LSTM} at Turbofan
    Prognostics},
  date = {2026-05-28},
  url = {https://alvinalias.com/notes/posts/rolling-mean-vs-lstm.html},
  langid = {en}
}
</code></pre><div class="quarto-appendix-secondary-label">For attribution, please cite this work as:</div><div id="ref-alias2026" class="csl-entry quarto-appendix-citeas">
Alias, Alvin. 2026. <span>“How a 30-Cycle Rolling Mean Beat a Deep LSTM
at Turbofan Prognostics.”</span> May 28, 2026. <a href="https://alvinalias.com/notes/posts/rolling-mean-vs-lstm.html">https://alvinalias.com/notes/posts/rolling-mean-vs-lstm.html</a>.
</div></div></section></div> ]]></description>
  <category>entry</category>
  <category>tabular-ml</category>
  <category>prognostics</category>
  <guid>https://alvinalias.com/notes/posts/rolling-mean-vs-lstm.html</guid>
  <pubDate>Thu, 28 May 2026 07:00:00 GMT</pubDate>
</item>
</channel>
</rss>
