<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bromena</title>
    <description>The latest articles on DEV Community by Bromena (@bromena).</description>
    <link>https://dev.to/bromena</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4241683%2Fdf1421e8-05e1-4889-b680-2dbc7baeed76.png</url>
      <title>DEV Community: Bromena</title>
      <link>https://dev.to/bromena</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bromena"/>
    <language>en</language>
    <item>
      <title>Retraining on a schedule is easy. Promoting on a schedule is how models quietly get worse</title>
      <dc:creator>Bromena</dc:creator>
      <pubDate>Sat, 14 Nov 2026 18:47:59 +0000</pubDate>
      <link>https://dev.to/bromena/retraining-on-a-schedule-is-easy-promoting-on-a-schedule-is-how-models-quietly-get-worse-1h7n</link>
      <guid>https://dev.to/bromena/retraining-on-a-schedule-is-easy-promoting-on-a-schedule-is-how-models-quietly-get-worse-1h7n</guid>
      <description>&lt;p&gt;A lot of teams get to the same place with their first production model: a cron job retrains it every week, and every week the new version replaces the old one. It feels like automation. It's actually a coin flip with a deploy button.&lt;/p&gt;

&lt;p&gt;Retraining is the safe part. &lt;strong&gt;Promotion&lt;/strong&gt; is where a model gets worse without anyone noticing. Here's the small set of checks we'd put between "a new model exists" and "users are getting its predictions", with code you can lift.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Promote against a frozen eval set, not against last week
&lt;/h2&gt;

&lt;p&gt;The new model should earn its place by beating the current one &lt;strong&gt;on the same evaluation set&lt;/strong&gt;. If the eval set changed, the comparison means nothing, and the gate should say so instead of guessing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;champion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;challenger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_gain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_latency_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Promote only if the challenger beats the champion on the same frozen eval set and stays inside the latency budget."""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;challenger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;champion&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"different eval sets: not comparable"&lt;/span&gt;
    &lt;span class="n"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;challenger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;champion&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_gain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"gain {gain:+.4f} below {min_gain}"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;challenger&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"p95_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_latency_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"p95 latency {challenger['p95_latency_ms']} ms over budget"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"promote: gain {gain:+.4f}"&lt;/span&gt;

&lt;span class="n"&gt;champ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"eval-2026-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.861&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"p95_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;champ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"eval-2026-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.864&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"p95_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;champ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"eval-2026-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.874&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"p95_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;champ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"eval_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"eval-2026-11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"auc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.902&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"p95_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gain +0.0030 below 0.005'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'promote: gain +0.0130'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'different eval sets: not comparable'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third case is the one that bites. A challenger scored on a newer, easier eval set looks like a big win. It isn't evidence of anything.&lt;/p&gt;

&lt;p&gt;Two choices matter more than the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;min_gain&lt;/code&gt; is a product decision.&lt;/strong&gt; A gain smaller than your metric's run-to-run noise is noise. Measure that noise once (retrain the same model with different seeds) and set the threshold above it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency is part of "better".&lt;/strong&gt; A model that's 1% more accurate and blows the p95 budget is a regression for the people waiting on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Watch the inputs, and agree on thresholds before anything pages anyone
&lt;/h2&gt;

&lt;p&gt;Drift alerts are only useful if someone agreed in advance what counts as drift and who gets woken up. A common, simple input check is the Population Stability Index (PSI) per feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Population Stability Index between a reference sample and a live sample of one feature."""&lt;/span&gt;
    &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# same distribution&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;112&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# shifted after a market change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.002
0.542
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A widely used rule of thumb reads PSI under 0.1 as stable and over 0.25 as a real shift. Treat those as a starting point, not a law: tune them per feature, write them down, and name the owner of each model who receives the alert. An alert that goes to a shared channel goes to nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ship with traffic you can take back
&lt;/h2&gt;

&lt;p&gt;Even a model that passed the gate can fail on live traffic. Two patterns keep that cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shadow:&lt;/strong&gt; the new model scores real requests, but its predictions aren't used. You compare offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary:&lt;/strong&gt; a small slice of traffic gets the new model, with an automatic stop if error rates jump.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both need one release path for every model, usually through a model registry, so "which version is live?" always has a single answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rehearse the rollback before you need it
&lt;/h2&gt;

&lt;p&gt;Every release path needs a way back to the last good version, and that way back needs to have been &lt;strong&gt;run at least once&lt;/strong&gt; before a real model depends on it. A rollback nobody has tried is a hope: the old artifact got garbage-collected, the feature schema moved on, or the one person who knew the command is on holiday.&lt;/p&gt;

&lt;p&gt;A practical version: in the same week you set up promotion, deploy a deliberately worse model to staging, let the canary stop fire, and roll back. Time it. Write the steps down.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Size the platform to the models you have
&lt;/h2&gt;

&lt;p&gt;You don't need a full Kubeflow install to do any of this. With a handful of models, MLflow (or any registry) plus a scheduled pipeline usually covers it. Start by automating the riskiest manual step in how your models ship today, and add pieces only when a release actually needs them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist to steal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Retraining can run on a schedule; &lt;strong&gt;promotion&lt;/strong&gt; runs through a gate.&lt;/li&gt;
&lt;li&gt;[ ] The gate compares champion and challenger on the &lt;strong&gt;same frozen eval set&lt;/strong&gt;, with a minimum gain above measured noise and a latency budget.&lt;/li&gt;
&lt;li&gt;[ ] Drift thresholds are written down per feature, with &lt;strong&gt;one named owner&lt;/strong&gt; per model.&lt;/li&gt;
&lt;li&gt;[ ] New versions go out as shadow or canary, with an automatic stop.&lt;/li&gt;
&lt;li&gt;[ ] The rollback has been &lt;strong&gt;rehearsed&lt;/strong&gt; and timed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How we scope this kind of platform work, what our MLOps engineers are screened on, and the stack that comes up most often are at &lt;a href="https://bromena.com/ai/mlops/" rel="noopener noreferrer"&gt;bromena.com/ai/mlops&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;— Bromena team&lt;/p&gt;

</description>
      <category>mlops</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Seven design scenarios for interviewing AI and data engineers, and what a pass looks like</title>
      <dc:creator>Bromena</dc:creator>
      <pubDate>Fri, 13 Nov 2026 13:30:09 +0000</pubDate>
      <link>https://dev.to/bromena/seven-design-scenarios-for-interviewing-ai-and-data-engineers-and-what-a-pass-looks-like-4n3h</link>
      <guid>https://dev.to/bromena/seven-design-scenarios-for-interviewing-ai-and-data-engineers-and-what-a-pass-looks-like-4n3h</guid>
      <description>&lt;p&gt;A generic coding test tells you almost nothing about an AI engineer. A strong backend developer can still get evaluation design wrong. A strong data scientist can still struggle to serve a model under load. The mistakes differ by role, so the interview has to differ too.&lt;/p&gt;

&lt;p&gt;These are the design scenarios we use, one per role, with the evidence a candidate has to show to pass. Steal them for your own loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Machine learning engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; serve a ranking or forecasting model behind an API with a fixed latency budget, then plan how it gets retrained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an offline metric tied to the business outcome, not just accuracy;&lt;/li&gt;
&lt;li&gt;a test set with no leakage from training;&lt;/li&gt;
&lt;li&gt;a serving plan with its latency and cost stated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Generative AI engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; question answering over a large set of internal documents, with a ceiling on cost per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an evaluation set built &lt;em&gt;before&lt;/em&gt; any prompt tuning;&lt;/li&gt;
&lt;li&gt;retrieval measured on its own, separate from generation;&lt;/li&gt;
&lt;li&gt;a cost per request they can estimate and defend.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI agent developer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; an agent that opens and updates tickets through a ticketing API, where one tool times out halfway through a task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timeouts and retries for each tool;&lt;/li&gt;
&lt;li&gt;actions that are safe to run twice;&lt;/li&gt;
&lt;li&gt;a log of every step;&lt;/li&gt;
&lt;li&gt;human approval where a mistake costs money.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The timeout is the whole point of this one. Most demo agents have never met a tool that fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Computer vision engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; a defect detector that has to keep up with a production line camera on an edge device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt; a labeling guide with a check on label disagreement, metrics for each class, and latency measured on the device itself, not on a laptop GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  NLP engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; pull fields from scanned contracts with inconsistent layouts and OCR errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt; error analysis field by field, a simple baseline before any large model, and a plan for documents the model can't read.&lt;/p&gt;

&lt;h2&gt;
  
  
  MLOps engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; release a new model version through a canary, with an automatic rollback trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt; versioned models and data, a rollout that stops itself on a bad metric, drift alerts with an owner, and a rollback they have actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; design the tables a training pipeline reads from, with late-arriving events and an upstream schema change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass shows:&lt;/strong&gt; a data model that keeps history, quality checks that stop a bad batch before training, and backfills that can rerun without duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rules that make the scenarios work
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Change a constraint halfway through.&lt;/strong&gt; Halve the budget or tighten the latency target and watch how the design holds up. Candidates who memorised an architecture stall; candidates who understand it adjust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score each stage on its own.&lt;/strong&gt; Someone who aces the coding screen and struggles in the design review shouldn't average out into a pass. Write down the evidence behind every score, so the hiring manager can read it later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We run these as part of a four-stage evaluation at &lt;a href="https://bromena.com/how-we-vet/" rel="noopener noreferrer"&gt;Bromena&lt;/a&gt;, and the client still runs its own interviews afterwards. If you use a different scenario for one of these roles, we'd like to hear what it catches that ours misses.&lt;/p&gt;

&lt;p&gt;— Bromena team&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>hiring</category>
      <category>career</category>
    </item>
    <item>
      <title>How to compare a nearshore engineer to a US hire using only public BLS data</title>
      <dc:creator>Bromena</dc:creator>
      <pubDate>Thu, 12 Nov 2026 19:17:45 +0000</pubDate>
      <link>https://dev.to/bromena/how-to-compare-a-nearshore-engineer-to-a-us-hire-using-only-public-bls-data-349k</link>
      <guid>https://dev.to/bromena/how-to-compare-a-nearshore-engineer-to-a-us-hire-using-only-public-bls-data-349k</guid>
      <description>&lt;p&gt;US hiring teams often compare a nearshore rate with a US salary and stop there. That comparison is incomplete: a salaried employee also costs benefits, paid leave and the employer's payroll taxes. The US government publishes those pieces. You can rebuild a fully loaded US cost from them, then compare it with an all-in hourly rate, without relying on a vendor's private spreadsheet.&lt;/p&gt;

&lt;p&gt;This walk-through uses only official Bureau of Labor Statistics (BLS) releases, plus a worked example with the all-in ranges &lt;a href="https://bromena.com/tools/developer-salary-calculator/" rel="noopener noreferrer"&gt;Bromena&lt;/a&gt; publishes for AI and data roles. Every arithmetic step is below, so you can swap in your own salary or rate and rerun it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Start with a US wage that actually has a BLS category
&lt;/h3&gt;

&lt;p&gt;BLS does not publish a national wage series named "machine learning engineer" or "data engineer". The closest occupation with national data is software developers.&lt;/p&gt;

&lt;p&gt;From the Occupational Outlook Handbook, pay for May 2025:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Median annual wage: $135,980&lt;/li&gt;
&lt;li&gt;Highest 10 percent (the 90th percentile): more than $214,670&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source: &lt;a href="https://www.bls.gov/ooh/computer-and-information-technology/software-developers.htm" rel="noopener noreferrer"&gt;BLS, Occupational Outlook Handbook, Software Developers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team pays above those figures, the gap you calculate later will be wider. If you have a wage for the exact role you are filling, use that instead of the national median.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Turn the wage into a fully loaded employer cost
&lt;/h3&gt;

&lt;p&gt;Wages are not what a US employer spends. The BLS Employer Costs for Employee Compensation (ECEC) series splits private-industry compensation into wages and benefits.&lt;/p&gt;

&lt;p&gt;In June 2026, for private industry workers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total compensation averaged $46.89 per hour worked&lt;/li&gt;
&lt;li&gt;Wages and salaries averaged $32.82, or &lt;strong&gt;70.0 percent&lt;/strong&gt; of that total&lt;/li&gt;
&lt;li&gt;Benefit costs averaged $14.07, or the remaining 30.0 percent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The release date is 9 September 2026 (USDL-26-1494). Source: &lt;a href="https://www.bls.gov/news.release/ecec.nr0.htm" rel="noopener noreferrer"&gt;BLS, Employer Costs for Employee Compensation, June 2026&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Dividing a wage by 0.70 adds the benefits layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Median: $135,980 / 0.70 = &lt;strong&gt;$194,257&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;90th percentile: $214,670 / 0.70 = &lt;strong&gt;$306,671&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two caveats, both of which shrink the US figure relative to a real hire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ECEC's 70.0 percent is an average across private industry, not a software-only ratio. It is the official number you can cite without mixing surveys.&lt;/li&gt;
&lt;li&gt;This still leaves out recruiting fees and the months a role stays empty. Those costs are real, but they have no single public series that belongs in the same table, so they stay out of the comparison.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Price the same year with an all-in hourly rate
&lt;/h3&gt;

&lt;p&gt;An all-in nearshore rate already includes the engineer's pay and the vendor's fee. To put it on an annual basis, multiply by 2,080 hours, the full-time year BLS uses for annual wages.&lt;/p&gt;

&lt;p&gt;Worked example, using ranges Bromena reviewed in October 2026 for AI and data roles (the low end of each range is mid-level; the high end is lead):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;US hire, fully loaded&lt;/th&gt;
&lt;th&gt;Nearshore, 2,080 hours&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Roles other than data engineer, low end ($65/hour)&lt;/td&gt;
&lt;td&gt;$194,257&lt;/td&gt;
&lt;td&gt;$135,200&lt;/td&gt;
&lt;td&gt;30% less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same roles, high end ($105/hour)&lt;/td&gt;
&lt;td&gt;$306,671&lt;/td&gt;
&lt;td&gt;$218,400&lt;/td&gt;
&lt;td&gt;29% less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data engineer, low end ($58/hour)&lt;/td&gt;
&lt;td&gt;$194,257&lt;/td&gt;
&lt;td&gt;$120,640&lt;/td&gt;
&lt;td&gt;38% less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data engineer, high end ($88/hour)&lt;/td&gt;
&lt;td&gt;$306,671&lt;/td&gt;
&lt;td&gt;$183,040&lt;/td&gt;
&lt;td&gt;40% less&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The arithmetic, so you can check it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$65 × 2,080 = $135,200; ($194,257 − $135,200) / $194,257 ≈ 30%&lt;/li&gt;
&lt;li&gt;$105 × 2,080 = $218,400; ($306,671 − $218,400) / $306,671 ≈ 29%&lt;/li&gt;
&lt;li&gt;$58 × 2,080 = $120,640; ($194,257 − $120,640) / $194,257 ≈ 38%&lt;/li&gt;
&lt;li&gt;$88 × 2,080 = $183,040; ($306,671 − $183,040) / $306,671 ≈ 40%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloud, GPUs and model API usage are not in either column. They should run on the customer's accounts in both models.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. What this does not claim
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;It does not say every nearshore engineer costs 29 to 40 percent less. That range is the result of this method, with these inputs, for these published rates.&lt;/li&gt;
&lt;li&gt;It does not treat "software developers" as the same job as an ML or data engineer. It treats it as the closest official wage category, and says so.&lt;/li&gt;
&lt;li&gt;It does not include output, seniority mix, or the cost of a bad hire. Those belong in a hiring decision, not in this table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to rerun the same steps with a different US wage or a quoted rate, &lt;a href="https://bromena.com/tools/developer-salary-calculator/" rel="noopener noreferrer"&gt;Bromena&lt;/a&gt; publishes a calculator that follows this method and lists the same sources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sources
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;U.S. Bureau of Labor Statistics, Occupational Outlook Handbook, Software Developers, pay for May 2025: &lt;a href="https://www.bls.gov/ooh/computer-and-information-technology/software-developers.htm" rel="noopener noreferrer"&gt;https://www.bls.gov/ooh/computer-and-information-technology/software-developers.htm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;U.S. Bureau of Labor Statistics, Employer Costs for Employee Compensation, June 2026, released 9 September 2026 (USDL-26-1494): &lt;a href="https://www.bls.gov/news.release/ecec.nr0.htm" rel="noopener noreferrer"&gt;https://www.bls.gov/news.release/ecec.nr0.htm&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;— Bromena team&lt;/p&gt;

</description>
      <category>career</category>
      <category>salary</category>
      <category>datascience</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
