<?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: YMori</title>
    <description>The latest articles on DEV Community by YMori (@yasumorishima).</description>
    <link>https://dev.to/yasumorishima</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%2F3761588%2F10324d86-be69-483f-90af-7c8f8eb80cf1.png</url>
      <title>DEV Community: YMori</title>
      <link>https://dev.to/yasumorishima</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yasumorishima"/>
    <language>en</language>
    <item>
      <title>Two players, pick one on last season's numbers: how often is it right? MLB 2026, checked against rules fixed in advance</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Mon, 05 Oct 2026 10:09:48 +0000</pubDate>
      <link>https://dev.to/yasumorishima/two-players-pick-one-on-last-seasons-numbers-how-often-is-it-right-mlb-2026-checked-against-2bla</link>
      <guid>https://dev.to/yasumorishima/two-players-pick-one-on-last-seasons-numbers-how-often-is-it-right-mlb-2026-checked-against-2bla</guid>
      <description>&lt;p&gt;When players are compared, "his average was higher last year" or "his ERA was lower" often comes up first. There are also plenty of numbers that try to take the luck out of the results: xwOBA, FIP, K−BB% and so on. If one of two players has to be chosen, which of last season's numbers most often picks the one who does better the next season? I had long wondered about that.&lt;/p&gt;

&lt;p&gt;So I used public MLB data to repeat "pick one of two players on last season's numbers" hundreds of thousands of times, and counted how often each number picked right. For 2026, I wrote down the method and the reading rule and froze them in a repository before checking the result. I have no experience inside the game; this is just an analysis of public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data and method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Season stats for hitters and pitchers&lt;/strong&gt;: tables built from the MLB Stats API and Baseball Savant, 2015-2026. I used the player-season tables of my public &lt;a href="https://huggingface.co/datasets/yasumorishima/mlb-stats" rel="noopener noreferrer"&gt;Hugging Face dataset&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The method:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take every player with at least 100 plate appearances (hitters) or 100 batters faced (pitchers) in both a season (year 1) and the next season (year 2)&lt;/li&gt;
&lt;li&gt;Pair up &lt;strong&gt;every two players&lt;/strong&gt; of the same season (pitchers only with the same role: starters with starters, relievers with relievers)&lt;/li&gt;
&lt;li&gt;Pick one of the two on a year-1 number: for hitters the higher one, for pitchers the lower ERA, FIP or xERA, or the higher K−BB%&lt;/li&gt;
&lt;li&gt;The pick is right if the picked player did better in year 2: higher wOBA for hitters, lower ERA for pitchers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A pair that a rule cannot split (equal year-1 numbers) counts as half right. A share of 50% means the rule does no better than a coin flip.&lt;/p&gt;

&lt;p&gt;Scoring the pairs looks like this in Python (simplified; the real code also filters the players and computes the intervals):&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="c1"&gt;# x: year-1 number (oriented so that higher is better), y: year-2 result (higher is better)
&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:]&lt;/span&gt;          &lt;span class="c1"&gt;# year-1 difference for every pair
&lt;/span&gt;&lt;span class="n"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:]&lt;/span&gt;          &lt;span class="c1"&gt;# year-2 difference for every pair
&lt;/span&gt;&lt;span class="n"&gt;right&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;&amp;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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# right if the pick is also ahead in year 2
&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dy&lt;/span&gt; &lt;span class="o"&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;&amp;amp;&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;eye&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# drop year-2 ties and self-pairs
&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers compared (definitions in the appendix):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hitters&lt;/strong&gt;: wOBA, xwOBA, wRC+, and a blend of five numbers (xwOBA carries the most weight)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitchers&lt;/strong&gt;: ERA, FIP, xERA, K−BB%, and a blend of the four&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The blends were fitted only on pairs whose year 1 is 2015-2024 (year 2 up to 2025).&lt;/p&gt;

&lt;h3&gt;
  
  
  What was fixed in advance
&lt;/h3&gt;

&lt;p&gt;I built the method on 2015-2024 (dropping the seasons where year 1 or year 2 is the 60-game 2020), then &lt;strong&gt;picked on 2025 numbers and scored on 2026 results&lt;/strong&gt;. Before reading the 2026 results, I froze the rule in the repository: "for the 2025→2026 pairs, report each number's share picked right and its difference from the base number (wOBA for hitters, ERA for pitchers). If the whole 95% interval of the difference is above 0, the number picks right more often; if it is wholly below 0, it picks right less often; if it crosses 0, the two are indistinguishable." In fact the pitcher version was scored first; the hitter version was written in the same form and frozen after I had seen the pitcher result, then scored. The intervals come from 2,000 resamples of players with replacement. The same player sits in hundreds of pairs, so resampling pairs instead of players would make the intervals far too narrow.&lt;/p&gt;

&lt;p&gt;The 2026 test has 353 hitters (62,126 pairs) and 359 pitchers (32,122 pairs).&lt;/p&gt;

&lt;h2&gt;
  
  
  Hitters: last season's xwOBA picked right 65% of the time, wOBA 61%
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc99k16vszzfhkdubzlk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc99k16vszzfhkdubzlk.png" alt="Hitters: last season's xwOBA picked right 65% of the time, wOBA 61%" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Picking on last season's wOBA was right 61.4% of the time. xwOBA was right 65.1% and the blend 65.4%. wRC+ was 61.6%, about the same as wOBA.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfvzguso7j5iysrtgvcc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfvzguso7j5iysrtgvcc.png" alt="Hitters: xwOBA and the blend beat wOBA. Pitchers: none separable from ERA" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Against wOBA, xwOBA was +3.7 points (95% interval +1.3 to +6.1) and the blend +3.9 points (+1.9 to +6.1); both intervals are above 0, so by the rule fixed in advance both pick right more often than wOBA. wRC+ was +0.2 points (−0.3 to +0.6): indistinguishable.&lt;/p&gt;

&lt;p&gt;Note that wRC+ adjusts for park and league, while the outcome here (next season's wOBA) is not adjusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitchers: 56-58% whichever number, none separable from ERA
&lt;/h2&gt;

&lt;p&gt;Picking pitchers on last season's ERA was right 55.8% of the time. FIP was 57.1%, xERA 58.1%, K−BB% 58.4% and the blend 58.5%. All are a little above ERA, but every 95% interval of the difference (+1.2 to +2.6 points) crosses 0, so by the rule all four are indistinguishable from ERA.&lt;/p&gt;

&lt;p&gt;"Indistinguishable" does not mean "no difference". It means one season of pairs was not enough to tell a difference of this size apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026 came out in roughly the same order as the development seasons
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60ilt3qw4jbajypfbt5l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60ilt3qw4jbajypfbt5l.png" alt="2026 came out in roughly the same order as the development seasons" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here 2026 sits next to 2015-2024, the seasons the method was built on (486,143 hitter pairs, 259,065 pitcher pairs). White is that period, blue is 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hitters' wOBA fell from 63.8% to 61.4%, while xwOBA barely moved (65.8% to 65.1%), so the gap widened in 2026 (+2.0 to +3.7 points)&lt;/li&gt;
&lt;li&gt;Pitchers' ERA was 55.8% both times. The other numbers were 0.9 to 1.6 points lower in 2026, so their gap to ERA shrank&lt;/li&gt;
&lt;li&gt;In 2015-2024, FIP, xERA and K−BB% did beat ERA for pitchers (+2.9 to +3.6 points, intervals clear of 0). The case that these numbers pick pitchers better than ERA rests on that period&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When wOBA and xwOBA disagree, xwOBA was right 59% of the time
&lt;/h2&gt;

&lt;p&gt;In most pairs every number picks the same player. The choice of number only matters in the pairs where two numbers point at different players, so here are those pairs alone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hghfj8yyw9g8owje7jm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hghfj8yyw9g8owje7jm.png" alt="When wOBA and xwOBA disagree, xwOBA was right 59% of the time" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;wOBA and xwOBA pointed at different hitters in 12,195 of 62,126 pairs (about a fifth); xwOBA was right in 59% of them and wOBA in 41%. ERA and K−BB% pointed at different pitchers in 11,028 of 32,122 pairs (about a third); K−BB% was right in 54% and ERA in 46%. The 95% interval of the difference is +6.9 to +30.1 points for hitters (xwOBA minus wOBA, +18.7), clear of 0, but −3.2 to +18.9 for pitchers (K−BB% minus ERA, +7.4), which crosses 0: one season cannot separate them. In 2015-2024 the figures were 56% for xwOBA and 56% for K−BB%.&lt;/p&gt;

&lt;h3&gt;
  
  
  One pair at a time
&lt;/h3&gt;

&lt;p&gt;Here are a few pairs where the numbers disagree sharply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hitters: Harrison Bader and Salvador Perez&lt;/strong&gt; (2025)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;wOBA&lt;/th&gt;
&lt;th&gt;xwOBA&lt;/th&gt;
&lt;th&gt;PA&lt;/th&gt;
&lt;th&gt;2026 wOBA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Harrison Bader&lt;/td&gt;
&lt;td&gt;.346&lt;/td&gt;
&lt;td&gt;.297&lt;/td&gt;
&lt;td&gt;501&lt;/td&gt;
&lt;td&gt;.238&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salvador Perez&lt;/td&gt;
&lt;td&gt;.311&lt;/td&gt;
&lt;td&gt;.357&lt;/td&gt;
&lt;td&gt;641&lt;/td&gt;
&lt;td&gt;.285&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;wOBA picks Bader, xwOBA picks Perez. Bader's results were .049 better than his quality of contact predicted, Perez's .046 worse. In 2026 Perez was ahead: xwOBA was right. Note, though, that Bader had only 111 PA in 2026.&lt;/p&gt;

&lt;p&gt;Nor is xwOBA always right. Pairing the same Perez with Jacob Wilson (2025 wOBA .348, xwOBA .304, 523 PA), Wilson had .301 in 2026 and Perez .285: wOBA was right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pitchers: Brayan Bello and Dylan Cease&lt;/strong&gt; (2025, both starters)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ERA&lt;/th&gt;
&lt;th&gt;K−BB%&lt;/th&gt;
&lt;th&gt;FIP&lt;/th&gt;
&lt;th&gt;xERA&lt;/th&gt;
&lt;th&gt;Batters faced&lt;/th&gt;
&lt;th&gt;2026 ERA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brayan Bello&lt;/td&gt;
&lt;td&gt;3.35&lt;/td&gt;
&lt;td&gt;9.3%&lt;/td&gt;
&lt;td&gt;4.19&lt;/td&gt;
&lt;td&gt;4.48&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;4.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dylan Cease&lt;/td&gt;
&lt;td&gt;4.55&lt;/td&gt;
&lt;td&gt;19.9%&lt;/td&gt;
&lt;td&gt;3.56&lt;/td&gt;
&lt;td&gt;3.46&lt;/td&gt;
&lt;td&gt;722&lt;/td&gt;
&lt;td&gt;2.40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ERA picks Bello, K−BB% picks Cease. Cease struck out many more than he walked yet had a 4.55 ERA; in 2026 his ERA was 2.40. K−BB% was right.&lt;/p&gt;

&lt;p&gt;On the other hand, with Clay Holmes (2025 ERA 3.53, K−BB% 8.9%) and Jack Flaherty (ERA 4.64, K−BB% 18.9%), Holmes had 3.29 in 2026 and Flaherty 4.48: ERA was right.&lt;/p&gt;

&lt;p&gt;Hand-picked pairs alone prove nothing, so here are all such pairs counted together. Among pitcher pairs where both faced 500+ batters and the one with the ERA lower by more than 1.00 was not the one with the K−BB% higher by more than 6 points, there were 38 pairs and K−BB% was right in 55% (21); with only 38 pairs, that cannot be told apart from a coin flip. Among hitter pairs where both had 500+ PA and the one with the wOBA higher by more than .025 was not the one with the xwOBA higher by more than .025, there were 56 pairs and xwOBA was right in 35 of them (62.5%). Both counts were made after the results were read, for reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hitters picked on xwOBA had a next-season wOBA .019 higher, over all pairs
&lt;/h2&gt;

&lt;p&gt;The share picked right does not say how far apart the picked and the unpicked player end up the next season. Here is the mean next-season gap between the player picked and the one left (wrong picks count as negative).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rfpyqxkq4zrtgh29pcx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rfpyqxkq4zrtgh29pcx.png" alt="Hitters picked on xwOBA had a next-season wOBA .019 higher, over all pairs" width="799" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For hitters, picking on wOBA gave a next-season wOBA .014 higher on average, xwOBA .019. For pitchers, picking on ERA gave a next-season ERA 0.23 lower, K−BB% 0.37 lower. In both cases picking on xwOBA or K−BB% gives the bigger gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hitters: xwOBA takes out the luck on batted balls
&lt;/h3&gt;

&lt;p&gt;wOBA is computed from the actual result of each plate appearance. xwOBA replaces each batted ball with the value expected mostly from its exit velocity and launch angle. The difference between the two contains batted-ball luck: whether the same ball goes straight to a fielder or finds a gap.&lt;/p&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/yasumorishima/does-a-pitchs-performance-carry-over-to-next-season-whiff-rate-vs-run-value-on-8022-mlb-pairs-bj"&gt;previous article&lt;/a&gt; on pitch-level results, batted-ball luck hardly carried over to the next season either. For hitters too, plotting last season's wOBA and xwOBA against next season's wOBA, xwOBA gives the clearer upward slope.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8w7ytc5v4nk92kudmpg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8w7ytc5v4nk92kudmpg.png" alt="Last season's xwOBA tracks next season's wOBA more closely" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Over the 353 hitters of the 2026 test, the correlation with next season's wOBA is 0.32 for wOBA and 0.42 for xwOBA.&lt;/p&gt;

&lt;h3&gt;
  
  
  How hard the outcome itself is: same-season numbers get about 80%
&lt;/h3&gt;

&lt;p&gt;To see why the numbers were harder to tell apart for pitchers, I measured how hard the outcome itself is to pick. What if the 2026 ERA is picked using the &lt;strong&gt;same season's&lt;/strong&gt; 2026 FIP? Same pairs, same scoring as the picks on last season's numbers. It is a reference value that could never be used in practice, and it is not part of the method fixed in advance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14z0kx3q0so7w7zn9yx2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14z0kx3q0so7w7zn9yx2.png" alt="Same-season numbers picked right about 80% of the time (reference)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Even knowing the same season's FIP, the lower ERA was picked 77% of the time (xERA 76%, K−BB% 67%). For hitters, the same season's xwOBA picked the higher wOBA 82% of the time. Even with same-season numbers, around a fifth of pairs come out the other way. For hitters, xwOBA and wOBA differ only in the results of batted balls (out, single, extra-base hit), so that is where the reversals come from. For pitchers, ERA also depends on whether hits come with runners on and on the fielders behind them, which FIP leaves out.&lt;/p&gt;

&lt;p&gt;Picking on last season's numbers, hitters land 11 points (wOBA) or 15 points (xwOBA) above a coin flip, pitchers 6 points (ERA) or 8 points (K−BB%). For pitchers no number gets far from a coin flip, and the numbers differ from each other by only 1 to 3 points. One season's intervals are ±3 to 4 points wide, too wide to separate gaps of that size. Pooling the eight seasons of 2015-2024 narrows the intervals to about ±1 point, and gaps of the same size then clear 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  By plate appearances, xwOBA had the higher share in every band
&lt;/h3&gt;

&lt;p&gt;I split the pairs into three bands by the smaller of the two hitters' 2025 PA. The pre-registration lists this as reported only, not used for conclusions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fol9hwhi7jkkp0p7k7qvm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fol9hwhi7jkkp0p7k7qvm.png" alt="In every PA band, xwOBA had the higher share picked right (reported only)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;xwOBA had the higher share in every band, but in the 500+ PA band the interval of the difference crosses 0 (−1.8 to +7.2 points). In 2015-2024 too, the 500+ band had a small gap between xwOBA and wOBA (0.7 points) with an interval crossing 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Picking one of two hitters on last season's numbers, xwOBA was right 65% of the time in 2026 and wOBA 61%, and the gap held up on one season alone. wRC+ did about as well as wOBA&lt;/li&gt;
&lt;li&gt;Picking one of two pitchers, ERA, FIP, xERA, K−BB% and the blend were all right 56-58% of the time, and 2026 alone could not separate them from ERA. In 2015-2024 the numbers other than ERA did pick better&lt;/li&gt;
&lt;li&gt;Where wOBA and xwOBA point at different hitters, xwOBA was right 59% of the time. For pitchers, K−BB% was right 54% against ERA, which one season cannot separate&lt;/li&gt;
&lt;li&gt;For reference, even same-season FIP or xwOBA picks right about 80% of the time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What surprised me most is that picking the player with the better number last season is still wrong about 4 times in 10. For hitters, picking on xwOBA was right more often than picking on wOBA, and that is the clearest result here. For pitchers one season says little, and I would like to keep checking it for a few more seasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Terms
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;wOBA&lt;/strong&gt;: an overall batting measure that weights each plate-appearance outcome (walk, single, home run, ...) by its run value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;xwOBA&lt;/strong&gt;: wOBA with each batted ball replaced by the value expected mostly from its exit velocity and launch angle (sprint speed is also used for some weakly hit balls)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wRC+&lt;/strong&gt;: batting run contribution adjusted for park and league, with league average = 100&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ERA&lt;/strong&gt;: earned runs per nine innings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FIP&lt;/strong&gt;: a pitcher's expected run allowance from strikeouts, walks, hit-by-pitches and home runs only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;xERA&lt;/strong&gt;: an ERA estimate from the exit velocity and launch angle of the batted balls a pitcher allowed, plus strikeouts, walks and hit-by-pitches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K−BB%&lt;/strong&gt;: strikeouts minus walks, as a share of batters faced&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blend&lt;/strong&gt;: a combination of year-1 numbers fitted on 2015-2024 to predict year 2. Hitters: wOBA, xwOBA, strikeout rate, walk rate and ISO (slugging minus average). Pitchers: ERA, FIP, xERA and K−BB%. In the hitter blend xwOBA carries the most weight (about 60% by coefficient times standard deviation) and wOBA almost none&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Caveats
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Only players with 100+ PA (batters faced) in year 2 are counted. Players whose playing time dropped after a bad season drop out, so this compares players who kept playing. With a 50 bar in year 2, the hitters' order is the same; for pitchers xERA and K−BB% swap places (58.9% and 58.8%)&lt;/li&gt;
&lt;li&gt;A pitcher is a starter if at least half of his year-1 games were starts, otherwise a reliever; only same-role pairs are formed&lt;/li&gt;
&lt;li&gt;Pairs a rule cannot split count as half right; pairs with equal year-2 results are not counted&lt;/li&gt;
&lt;li&gt;The same player appears in hundreds of pairs, so tens of thousands of pairs do not carry that much information; the intervals resample players&lt;/li&gt;
&lt;li&gt;The strong-disagreement counts (500+ PA or batters faced, an ERA gap of more than 1.00, and so on) and the same-season reference values were computed after the results were read and are not part of the method fixed in advance&lt;/li&gt;
&lt;li&gt;Fielding, baserunning, park, age, health and contracts are not included. Choosing real players involves a lot more than this&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reproducing
&lt;/h3&gt;

&lt;p&gt;All code, the rules fixed in advance and the results are in &lt;a href="https://github.com/yasumorishima/mlb-data-pipeline" rel="noopener noreferrer"&gt;mlb-data-pipeline&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/yasumorishima/mlb-data-pipeline/tree/master/analysis/decision_pairs_batters" rel="noopener noreferrer"&gt;&lt;code&gt;analysis/decision_pairs_batters/&lt;/code&gt;&lt;/a&gt;: hitters (&lt;code&gt;PREREG.md&lt;/code&gt; is the rule fixed in advance, &lt;code&gt;RESULTS.md&lt;/code&gt; the result)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/yasumorishima/mlb-data-pipeline/tree/master/analysis/decision_pairs" rel="noopener noreferrer"&gt;&lt;code&gt;analysis/decision_pairs/&lt;/code&gt;&lt;/a&gt;: pitchers (same). &lt;code&gt;article_dig.py&lt;/code&gt; computes the extra numbers for this article and &lt;code&gt;article_figs.py&lt;/code&gt; draws the charts&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>baseball</category>
      <category>datascience</category>
      <category>python</category>
      <category>statistics</category>
    </item>
    <item>
      <title>How well do player projections predict team wins? I froze MLB 2026 first, then checked</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Sun, 04 Oct 2026 23:53:04 +0000</pubDate>
      <link>https://dev.to/yasumorishima/how-well-do-player-projections-predict-team-wins-i-froze-mlb-2026-first-then-checked-3ih1</link>
      <guid>https://dev.to/yasumorishima/how-well-do-player-projections-predict-team-wins-i-froze-mlb-2026-first-then-checked-3ih1</guid>
      <description>&lt;p&gt;Every spring there are plenty of "how many games will each team win" projections. Most of them build a forecast for each player and add the players up by team. I had long wondered how well that actually works.&lt;/p&gt;

&lt;p&gt;So I built one of my own and checked it against the 2026 MLB season. I froze the projections in a repository before looking at the results, so nothing could be adjusted afterwards. I have no experience inside the game; this is just an analysis of public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data and method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Player stats&lt;/strong&gt;: batting and pitching from the MLB Stats API, 2015-2026, every player&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rosters&lt;/strong&gt;: each team's &lt;strong&gt;40-man roster on opening day&lt;/strong&gt; (including the 60-day injured list), from the same API (only what is known before the season)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The answer&lt;/strong&gt;: final standings (wins, runs scored, runs allowed) from the same API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The player forecasts are based on &lt;strong&gt;Marcel&lt;/strong&gt;, a deliberately simple method by Tom Tango: weight the last three seasons (newer ones more), pull the result toward league average, and adjust a little for age. It is often used as the baseline other systems are compared with. The real Marcel projects each component (home runs, walks, strikeouts, ...) separately; mine is a simplified version that applies its weights, regression, playing time and age factor to wOBA and FIP directly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Batters&lt;/strong&gt;: wOBA relative to league average, three seasons weighted 5:4:3, regressed with 1,200 PA of league average&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitchers&lt;/strong&gt;: FIP relative to league average, weighted 3:2:1, regressed with 134 innings of league average&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playing time&lt;/strong&gt;: half of last season plus a tenth of the season before, plus 200 PA for batters and 60 innings (starters) or 25 (relievers) for pitchers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team&lt;/strong&gt;: add up the opening-day 40-man roster into runs scored and allowed, then turn them into wins with the Pythagorean formula&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For one batter it looks like this in Python (simplified; the real code also adjusts for age and makes league runs scored and allowed match):&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="c1"&gt;# h: the player's last three seasons, one row per season
# rel = that season's wOBA minus that season's league wOBA
&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;y&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3&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="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# pull toward league average
&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pa_last&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pa_before&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;                    &lt;span class="c1"&gt;# playing time
&lt;/span&gt;&lt;span class="n"&gt;runs_above_avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;woba_scale&lt;/span&gt;                         &lt;span class="c1"&gt;# runs above an average hitter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A team's runs scored are league-average runs plus the sum of its players' runs above average; runs allowed are built the same way from the pitchers. Plate appearances and innings are scaled so each team gets 162 games' worth.&lt;/p&gt;

&lt;p&gt;To have something to beat, I used three floors that take no work at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every team .500&lt;/strong&gt;: 81 wins each&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last season's record&lt;/strong&gt;: the same winning percentage as last year&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last season's Pythagenpat&lt;/strong&gt;: last year's winning percentage implied by its runs scored and allowed (Pythagenpat is a version of the Pythagorean formula)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If adding up player forecasts is worth the effort, it should at least beat these three. The yardstick is the mean absolute error of wins over the 30 teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over nine past seasons, it beat last season's Pythagenpat in seven
&lt;/h2&gt;

&lt;p&gt;Before 2026 I ran the same thing on the nine seasons 2016-2025 (leaving out the 60-game 2020), projecting each season only from the seasons before it and taking the mean error over its 30 teams.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl649xemseyqmmq4zrw1t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl649xemseyqmmq4zrw1t.png" alt="Lower error than last season's Pythagenpat in 7 of 9 seasons" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The average over the nine seasons was &lt;strong&gt;8.27 wins&lt;/strong&gt;, against 10.60 for .500, 9.67 for last season's record and 9.34 for last season's Pythagenpat. It beat the toughest floor, Pythagenpat, in 7 of 9 seasons (not in 2019 or 2025).&lt;/p&gt;

&lt;p&gt;These nine seasons, though, are the ones I was looking at while making design choices, so they may flatter the method. Against Pythagenpat, the 95% bootstrap interval (resampling seasons) only just cleared zero, with an upper end of -0.01 to -0.05 depending on the random seed.&lt;/p&gt;

&lt;h2&gt;
  
  
  In 2026 it was slightly better than the floors, but not distinguishably
&lt;/h2&gt;

&lt;p&gt;For 2026 I wrote the projections to a file and merged them into the repository, together with the scoring rules, before fetching the standings. I froze them after the season had ended, but the projections only use information from before opening day; the point is that the method cannot be adjusted to fit the result. At scoring time I checked that the frozen files had not changed by a byte.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaqtifixhgfnnhe742mw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaqtifixhgfnnhe742mw.png" alt="2026: lower error than the floors, but not distinguishably" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The mean error was &lt;strong&gt;7.92 wins&lt;/strong&gt;, a little below all three floors. But the 95% intervals from resampling the 30 teams cross zero for every floor (against Pythagenpat: -0.80 wins, -2.82 to +1.36). By the rule set in advance, that is "indistinguishable".&lt;/p&gt;

&lt;p&gt;A version that stretches the projections away from .500 by a factor of 1.25 (fitted on the past seasons) did slightly worse in 2026, at 8.16.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big misses were MIL, TB, SF and ATH
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxm4rzhjcg8cbpg916zut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxm4rzhjcg8cbpg916zut.png" alt="The big misses were MIL, TB, SF and ATH" width="800" height="740"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Projected wins across, actual wins up; the closer to the dashed line, the better. MIL was projected for 84 and won 103, TB 80 and 98, SF 83 and 65, ATH 80 and 64. The correlation of projected and actual wins was 0.48 (0.63 over the nine past seasons).&lt;/p&gt;

&lt;h2&gt;
  
  
  The projections spread half as wide as the results
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8m3wz53qx8y2hvl7vr23.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8m3wz53qx8y2hvl7vr23.png" alt="The projections spread half as wide as the results" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 30 projected and actual win totals on one line: the projections sit between 67 and 93 wins, while the actual records ran from 58 to 103. Marcel pulls every player toward league average, so the teams built from them are pulled toward .500 too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even with the actual runs, about 4 wins of error remain
&lt;/h2&gt;

&lt;p&gt;A miss splits into two parts: the runs scored and allowed were projected wrong, or the team won more or fewer games than its runs suggest. The second part comes from things like close games, and no player forecast can remove it. So I asked how well the Pythagorean formula does if the actual runs scored and allowed were known.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmd5c9u9t3qu1j5a5466x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmd5c9u9t3qu1j5a5466x.png" alt="Even with the actual runs, about 4 wins of error remain" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the actual runs, the average miss is still &lt;strong&gt;3.94 wins&lt;/strong&gt;. The gap between the projected wins and the wins implied by the actual runs, which is the runs projection being off, averages 8.06 (runs scored were off by a standard deviation of 56, runs allowed by 68).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the miss is made of differs by team
&lt;/h2&gt;

&lt;p&gt;For the four big misses, I split the miss into those two parts. Blue is the runs projection (projected wins minus wins implied by actual runs), gray is record versus runs (wins implied by actual runs minus actual wins); together they make the miss.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmt7vja8igbof4ol615h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmt7vja8igbof4ol615h.png" alt="What the miss is made of differs by team" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Almost all of MIL's 19-win miss came from runs. TB's 10 wins from runs were topped up by 8 wins of beating its runs. ATH's runs projection was off by 21 wins, but it won 5 more than its runs suggest, shrinking the miss to 16. SF's 11 wins from runs were compounded by losing 6 more than its runs suggest.&lt;/p&gt;

&lt;h2&gt;
  
  
  MIL had two projected players beat their forecasts by 30+ runs
&lt;/h2&gt;

&lt;p&gt;I split MIL, the biggest miss, by player: how many runs above average each was projected to add (or save), against what they did. Batters use wOBA and pitchers FIP, converted to runs the same way as in the projection. Projected PA and innings are after scaling the roster to 162 games. Only players on the opening-day 40-man roster with a projection are shown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73p8k6c4p9pehhpwiyfg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73p8k6c4p9pehhpwiyfg.png" alt="MIL: two projected players beat their forecast by 30+ runs" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Jacob Misiorowski (pitcher) was projected for 97 innings and about 5 runs saved; he threw 169 innings and saved about 38. With few innings behind him, his forecast was pulled hard toward average and his innings were estimated low. Jake Bauers (batter) was projected for 309 PA at about league average and had 574 PA and about 31 runs above average. Better rates and more playing time both played a part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still, the projected players beating their forecasts explain only 40% of MIL's run gap
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7s31mr11yad8a7qtfenv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7s31mr11yad8a7qtfenv.png" alt="MIL: projected players beating their forecasts explain 40% of the run gap" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MIL scored 93 more runs than projected and allowed 96 fewer, about 190 runs in all. The projected batters beating their forecasts (+19 projected, +71 actual) account for 52 of them, the projected pitchers (by FIP, +46 to +70) for 24: about 40% together. The other 60% includes fielding, baserunning and batted-ball outcomes that FIP and wOBA do not see, and players without a projection; pitchers with no forecast threw 33% of MIL's innings.&lt;/p&gt;

&lt;h2&gt;
  
  
  20-30% of playing time went to players with no forecast
&lt;/h2&gt;

&lt;p&gt;The players without a projection are those not on the opening-day 40-man roster and rookies with no MLB history. I took their share of all 2026 plate appearances and innings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6x0zcswyaf1fl4zoes4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6x0zcswyaf1fl4zoes4q.png" alt="20-30% of playing time went to players with no forecast" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;They took 20% of plate appearances and 27% of innings. The projection assumes the projected players fill that time in proportion. Across the 30 teams, though, the correlation between a team's miss and its unprojected players' total WAR was a weak -0.27, with no clear link to the big misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Adding up player forecasts beat the floors over nine past seasons, but in the frozen 2026 test its mean error of 7.92 wins could not be told apart from the floors (8.50 to 8.83).&lt;/li&gt;
&lt;li&gt;Even with the actual runs, about 4 wins of error remain. The runs projections alone miss by about 8 wins on average, partly offset for some teams by winning more or fewer games than their runs suggest. The projections cluster toward .500 and spread half as wide as the results.&lt;/li&gt;
&lt;li&gt;In the biggest miss, MIL, projected players beating their forecasts explain about 40% of the run gap; the rest includes fielding, baserunning and unprojected players.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What surprised me most is that a gap of under one win cannot be seen in one season. A method can win over past seasons and still not show it in a single year of scoring, so comparing projection systems probably takes several years side by side. If I change anything next, it will be how rookies and new arrivals are handled, again frozen in advance, for 2027.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Terms
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;wOBA&lt;/strong&gt;: a batting rate that weights each outcome (walk, single, home run, ...) by its run value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FIP&lt;/strong&gt;: a pitcher's run estimate from strikeouts, walks, hit batters and home runs only, leaving out whether balls in play fall for hits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marcel&lt;/strong&gt;: Tom Tango's simple projection: a weighted average of three seasons, regressed toward average, with an age adjustment (applied here directly to wOBA and FIP)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pythagenpat&lt;/strong&gt;: the Pythagorean win formula with an exponent that depends on the run environment, ((runs scored + allowed) per game)^0.287&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Also checked
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do young teams beat their projection?&lt;/strong&gt; MIL made it look as if teams whose young players improve should beat their projections. Over 300 team-seasons (the nine past seasons and 2026), the correlation between the age of a team's projected batters (weighted by projected PA, relative to that season's average) and its miss was -0.01. Split into age quartiles, the average miss ranged from -0.7 to +1.6 wins with no order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Caveats
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;No fielding, park or schedule. Runs allowed come from FIP only, so defence is not in it.&lt;/li&gt;
&lt;li&gt;Opening-day 40-man rosters include players on the injured list or in the minors that day, so playing time is spread over more players than actually played.&lt;/li&gt;
&lt;li&gt;Shohei Ohtani is listed as a pitcher on the 2018-2019 rosters, so his batting is missing from the 2019 backtest (he is a two-way player from 2020 on).&lt;/li&gt;
&lt;li&gt;The interval for one season of 30 teams ignores that teams share league-wide trends, so it is narrower than it should be.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reproducing it
&lt;/h3&gt;

&lt;p&gt;Code, the frozen projections and the scoring rules are in &lt;a href="https://github.com/yasumorishima/mlb-data-pipeline/tree/master/forecast/team_wins" rel="noopener noreferrer"&gt;mlb-data-pipeline, forecast/team_wins&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fetch.py&lt;/code&gt;: opening-day 40-man rosters and standings from the MLB Stats API&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;project.py&lt;/code&gt;: Marcel forecasts and team wins&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;score.py&lt;/code&gt;: floors and scoring&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PREREG.md&lt;/code&gt;: the scoring rules written before the answer was known&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESULTS.md&lt;/code&gt;: the 2026 result&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>baseball</category>
      <category>datascience</category>
      <category>python</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Asking a Decision-Only AI (TypeSafe Jev) to Forecast Next Season's Hitting: Worth About 20 to 50 Training Examples</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Wed, 30 Sep 2026 10:24:22 +0000</pubDate>
      <link>https://dev.to/yasumorishima/asking-a-decision-only-ai-typesafe-jev-to-forecast-next-seasons-hitting-worth-about-20-to-50-360k</link>
      <guid>https://dev.to/yasumorishima/asking-a-decision-only-ai-typesafe-jev-to-forecast-next-seasons-hitting-worth-about-20-to-50-360k</guid>
      <description>&lt;p&gt;&lt;a href="https://docs.typesafe.ai" rel="noopener noreferrer"&gt;TypeSafe Jev&lt;/a&gt; is a decision-only model: it does not write text, it returns typed answers with probabilities. You can ask for a yes/no probability (Noul), probabilities over choices (Choice) or over ordered levels (Score).&lt;/p&gt;

&lt;p&gt;If it answers with probabilities, could it help with baseball's "what happens next year" questions? I wanted to know how often it is right and where it goes wrong, so I tried it on public MLB data. I have no experience working in baseball; I just tallied up public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to call Jev
&lt;/h2&gt;

&lt;p&gt;Jev is available through OpenRouter. All 697 calls used for evaluation in the two studies below fit in a new account's free allowance (no card, balance left at $0; the costs returned by the API add up to about $0.019 = 0.0107 + 0.0080).&lt;/p&gt;

&lt;p&gt;A call sends a &lt;code&gt;state&lt;/code&gt; describing the situation and typed &lt;code&gt;questions&lt;/code&gt;:&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="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typesafe/jev-1.13&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An anonymous MLB hitter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s 2025 regular season. ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wOBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xwOBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.33&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strikeout_rate_pct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# excerpt
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta_bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How will this hitter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s wOBA in 2026 compare with his 2025 wOBA?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criteria&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Big drop: ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Small drop: ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;About the same: ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Small rise: ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Big rise: ...&lt;/span&gt;&lt;span class="sh"&gt;"&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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://openrouter.ai/api/alpha/decisions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta_bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What comes back is only the probabilities, like &lt;code&gt;{"0": 0.02, "1": 0.32, ...}&lt;/code&gt;, with no explanation.&lt;/p&gt;

&lt;p&gt;Code, pre-registrations and every Jev answer are in &lt;a href="https://github.com/yasumorishima/jev-baseball" rel="noopener noreferrer"&gt;yasumorishima/jev-baseball&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  First study: will an ABS challenge be overturned?
&lt;/h2&gt;

&lt;p&gt;I first asked whether an ABS challenge (a player disputing a ball/strike call) would be overturned. Every challenge has a known answer, so the probabilities are easy to score.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the data
&lt;/h3&gt;

&lt;p&gt;In MLB StatsAPI game feeds (&lt;code&gt;feed/live&lt;/code&gt;), a challenged pitch carries &lt;code&gt;reviewDetails&lt;/code&gt;. From 777 games in August and September 2026 I extracted 2,678 pitch challenges (&lt;code&gt;reviewType == "MJ"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Two things needed care:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pitch's &lt;code&gt;details.call.description&lt;/code&gt; is the call &lt;strong&gt;after&lt;/strong&gt; the review. Using it would leak the answer, so the original call comes from who challenged (the batting side challenges strikes, the fielding side challenges balls).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;count&lt;/code&gt; on a pitch is the count &lt;strong&gt;after&lt;/strong&gt; that pitch. The count before it comes from the previous pitch.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A gap I found later.&lt;/strong&gt; While re-collecting a full season for another article, I found that when the final call ends the plate appearance (a called strike three or ball four), &lt;code&gt;reviewDetails&lt;/code&gt; sits on the &lt;strong&gt;play&lt;/strong&gt;, not on the pitch. This study read only the pitch, so of the 3,543 challenges from August 1 to September 27 collected again, 893 (25%) were missing (the 2,650 it did capture differ slightly from the original 2,678 because they were collected at different times). The missing ones were overturned 48.6% of the time, against 56.8% for the captured ones.&lt;/p&gt;

&lt;p&gt;All 250 challenges put to Jev came from the captured part, so this section describes challenges where the plate appearance went on. Refitting the distance-only formula on all of August and scoring all of September (1,650) gives a Brier score of 0.102. On the captured September challenges (1,238), the original formula scores 0.1121 and the refitted one 0.1123, nearly the same. The two sets differ, so 0.102 and 0.112 cannot be compared directly, but using every challenge did not make the formula worse. I did not ask Jev again (the free allowance is spent), so Jev's numbers cover only the captured part.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The distance to the zone depends on whether the ball's surface touches the zone, so it is "distance from the zone rectangle to the ball's center, minus the ball's radius" (which gives the zone rounded corners):&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;edge_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Inches from the ball&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s surface to the zone; + misses, - touches.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;                     &lt;span class="c1"&gt;# half the plate width (feet)
&lt;/span&gt;    &lt;span class="n"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pz&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;top&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;dx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pz&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.45&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;                    &lt;span class="c1"&gt;# minus the 1.45-inch ball radius
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked Jev about 250 challenges from September and compared it with a logistic regression on a single input, "inches on the wrong side of the original call", fitted on 1,431 August challenges. Jev was given the coordinates and zone top and bottom, plus this distance precomputed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result: the distance-only formula wins easily
&lt;/h3&gt;

&lt;p&gt;The Brier score (mean squared gap between the probability and the 0/1 outcome; lower is better) was &lt;strong&gt;0.113 for the distance-only formula and 0.180 for Jev&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbmlhrgr5v9zwq1xk1md.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbmlhrgr5v9zwq1xk1md.png" alt="On clear-cut pitches, only Jev stays hesitant" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I split the 250 into four groups by how far, and on which side, each pitch was from the original call. The 82 pitches at least an inch on the wrong side were overturned 99% of the time, and the formula said 98%. Jev's average probability was 65%. The 63 pitches more than an inch on the called side were never overturned, yet Jev gave them 18% on average.&lt;/p&gt;

&lt;p&gt;Near the edge (within an inch) both the formula and Jev are unsure. Even pitches within an inch on the called side were overturned 58% of the time: the edge computed from StatsAPI coordinates does not seem to reproduce the zone ABS actually uses right at the edge.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wrong task for this tool
&lt;/h3&gt;

&lt;p&gt;This result could have been predicted before measuring anything. ABS compares a tracked position with a fixed zone, so the answer is set by a rule; it is not a judgment call.&lt;/p&gt;

&lt;p&gt;So I switched to something &lt;strong&gt;not settled by a rule, and genuinely uncertain&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Main study: how much will a hitter's wOBA move next season?
&lt;/h2&gt;

&lt;p&gt;A hitter's next season is a mix of regression to the mean, luck, age, injuries and more. No rule settles it, but measures of the hitting itself (xwOBA, strikeout rate and so on) are available, so there is material to judge from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data and comparison
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; season hitting lines from my own MLB dataset (&lt;a href="https://huggingface.co/datasets/yasumorishima/mlb-stats" rel="noopener noreferrer"&gt;yasumorishima/mlb-stats on Hugging Face&lt;/a&gt;), built from Baseball Savant and MLB StatsAPI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hitters:&lt;/strong&gt; 227 with at least 250 plate appearances in both 2025 and 2026. The 82 who had 250 in 2025 but not in 2026 are not included, so this is about hitters who kept playing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Given to Jev:&lt;/strong&gt; 2025 PA, wOBA, xwOBA, strikeout and walk rates, ISO, BABIP, batted-ball mix (ground ball, fly ball, pulled air ball) and sprint speed; age and position in four bands. &lt;strong&gt;No name or team, and numbers rounded&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question:&lt;/strong&gt; which of five bins the 2026 change in wOBA falls in (Score)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bin edges split past year-to-year changes into fifths. The past pairs are the 1,620 consecutive seasons from 2016→2017 through 2024→2025 with 250+ PA in both (pairs touching the 60-game 2020 season excluded). The edges came out at −36, −14, +3 and +25 points (1 point = 0.001 of wOBA).&lt;/p&gt;

&lt;p&gt;Forecasts were scored with the RPS (Ranked Probability Score): the mean squared gap between the cumulative forecast and the cumulative outcome, so predicting "small rise" is punished more when the answer is "big drop" than when it is "big rise". Lower is better.&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;rps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# probs: 5 bin probabilities, b: actual bin (0-4)
&lt;/span&gt;    &lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three comparators:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guessing:&lt;/strong&gt; 20% in every bin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marcel-style rule:&lt;/strong&gt; pull toward the league mean by plate appearances and adjust a little for age, with no fitting. The forecast change is the center of a normal distribution whose width is the rule's error on the past pairs, turned into five bin probabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models fitted on past examples:&lt;/strong&gt; ridge regression fitted on n of the 1,620 past pairs
&lt;/li&gt;
&lt;/ul&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;marcel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                                             &lt;span class="c1"&gt;# 2025 league wOBA
&lt;/span&gt;    &lt;span class="n"&gt;proj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;woba&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;proj&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.006&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;age&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;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.003&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;proj&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;woba&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                                  &lt;span class="c1"&gt;# forecast change
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A model fitted on all the data will beat Jev, so the question is &lt;strong&gt;how many past examples a fitted model needs to catch up with Jev&lt;/strong&gt;. That number is a rough measure of how much baseball knowledge Jev brings with it, which matters most where data is scarce, such as new foreign players or rookies.&lt;/p&gt;

&lt;p&gt;To avoid reading the results in whatever way suited me, I committed the design and "which result means what" to the repository before calling Jev (&lt;a href="https://github.com/yasumorishima/jev-baseball/blob/master/study2-projection/PREREG.md" rel="noopener noreferrer"&gt;PREREG.md&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev's gain over guessing is a third of the Marcel-style rule's
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1s10mo2zhyzy63f26ro6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1s10mo2zhyzy63f26ro6.png" alt="Jev's gain over guessing is a third of the Marcel-style rule's" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Guessing scores an RPS of 0.195. The chart shows how far each method gets below that.&lt;/p&gt;

&lt;p&gt;Jev gets 0.015 below, better than guessing: the 95% interval of the difference over 10,000 resamples of hitters is [−0.029, −0.001] and excludes 0.&lt;/p&gt;

&lt;p&gt;The Marcel-style rule gets 0.046 below, and Jev does not reach it (difference +0.031, 95% interval [+0.017, +0.044]). By the pre-registered reading, &lt;strong&gt;Jev is worse than a textbook rule&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev is worth a model fitted on 20 to 50 past examples
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdpzsr34zl1lujzzq03su.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdpzsr34zl1lujzzq03su.png" alt="Jev is worth a model fitted on 20 to 50 past examples" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I fitted ridge regressions on n of the 1,620 past pairs (n = 10, 20, 50, 100, 300) and scored them on the same 227 hitters. Which examples are drawn matters, so each n is an average over 200 draws.&lt;/p&gt;

&lt;p&gt;On average, the model fitted on 20 examples is worse than Jev and the one on 50 is better. With no examples at all, Jev forecasts about as well as a model fitted on 20 to 50 examples.&lt;/p&gt;

&lt;p&gt;With 20 examples, though, the result swings a lot with the draw (95% range 0.157 to 0.242), and Jev's 0.180 is inside it. And only 20 and 50 were measured; where the lines cross is not a measured value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev rarely expects big moves
&lt;/h2&gt;

&lt;p&gt;From here on, the analysis was done after seeing the results. First, where did Jev lose points?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fug6z9m2evom8fltb82l6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fug6z9m2evom8fltb82l6.png" alt="Jev rarely expects big moves" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For each bin, the share of hitters who actually landed in it (gray) next to Jev's average probability for it (orange). The bins split past changes into fifths, so in past pairs each holds about 20%.&lt;/p&gt;

&lt;p&gt;In 2026, 15% of the 227 had a big drop and 22% a big rise. Jev gave these 2% and 9%. It put too much on "about the same" and "small rise".&lt;/p&gt;

&lt;p&gt;It is clearer hitter by hitter. I turned Jev's five probabilities into an "expected change" by weighting each bin's average change in the past pairs (−57, −24, −5, +14, +45 points).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F078wumea3581rpvge12j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F078wumea3581rpvge12j.png" alt="Jev's forecasts move only about 60% as much as the rule's" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The actual change is on the x-axis and the forecast change on the y-axis. Points on the dashed line are exact. Jev's forecasts stay within about ±30 points, a flat band.&lt;/p&gt;

&lt;p&gt;The standard deviation of the forecasts is 13 points for Jev and 21 for the rule, against 36 for the actual changes. Actual changes include luck, so even a good forecast moves less than reality; the fair comparison is with the rule, and Jev moves only about 60% as much.&lt;/p&gt;

&lt;p&gt;Because this "expected change" is built from bin averages, it cannot go below −57 points even if Jev puts 100% on "big drop". The bottom-left corner of the chart is partly a limit of that construction.&lt;/p&gt;

&lt;p&gt;The direction is right. The correlation of the forecast with the actual change is 0.57 for Jev and 0.62 for the rule, not far apart. The gap is in how far the forecasts move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev missed the stars' big drops, got the unlucky hitters right
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uvc52kqjgde36tfwgqc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uvc52kqjgde36tfwgqc.png" alt="Jev missed the stars' big drops, got the unlucky hitters right" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The number beside each name is the 2025 wOBA. Jev never saw names; to Jev, Judge was "wOBA .465 (rounded to 0.005), an outfielder aged 30 to 33".&lt;/p&gt;

&lt;p&gt;Aaron Judge had a .463 wOBA in 2025 and dropped 91 points in 2026. The rule, reasoning that such a high number regresses, forecast a 75-point drop; Jev forecast 20. Cal Raleigh (.392) and George Springer (.408) look the same: about 90 points down, with Jev at 15 to 23. No forecast, the rule included, caught drops this large.&lt;/p&gt;

&lt;p&gt;Jev did better on others. Henry Davis had a low .229 wOBA in 2025, 64 points below his xwOBA (.293), an unlucky year. He rose 34 points; Jev said +35, nearly exact, while the rule said +64, too high.&lt;/p&gt;

&lt;p&gt;Michael Conforto rose 55; Jev said +34 and the rule +11, so Jev was closer. His wOBA was 43 points below his xwOBA, and here it matters whether a forecast uses luck (next section).&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev uses luck, but not much regression to the mean
&lt;/h2&gt;

&lt;p&gt;What Jev leans on may explain these five. I regressed Jev's expected change on two inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Regression to the mean:&lt;/strong&gt; how far the 2025 wOBA was above the league mean&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Luck:&lt;/strong&gt; 2025 wOBA − xwOBA (the gap to the wOBA expected from exit velocity and launch angle)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and fitted the same regression to the actual changes in the 1,620 past pairs:&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="n"&gt;X&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;c_&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;ones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;woba_minus_league&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;woba_minus_xwoba&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;w_actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&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;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstsq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_delta_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rcond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 1,620 past pairs
&lt;/span&gt;&lt;span class="n"&gt;w_jev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&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;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstsq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jev_expected_delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rcond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# Jev's forecasts (227)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftopkoq42vnkr2jkarpve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftopkoq42vnkr2jkarpve.png" alt="Jev uses luck, but not much regression to the mean" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the past pairs, a hitter one point above the league mean gave back 0.43 points the next season, and one point of wOBA above xwOBA also gave back 0.43. In Jev's forecasts, the luck weight is 0.32, somewhat weaker than reality, but the regression weight is 0.20, less than half.&lt;/p&gt;

&lt;p&gt;The Marcel-style rule is the reverse: a strong regression weight (0.57) but, with no xwOBA in the formula, almost no luck weight (0.01). That is why the rule said only +11 for Conforto; the past-pair weights give +30.&lt;/p&gt;

&lt;p&gt;A star who really was hitting, like Judge, also has a high xwOBA, so there is little luck to give back. What is left is regression to the mean: −66 points with the past-pair weights, but Jev uses it weakly and stopped at −20 (the actual was −91, beyond even the weights' formula).&lt;/p&gt;

&lt;p&gt;For Henry Davis, the past-pair weights give +64, the same as the rule. Jev being right on Davis is one hitter's result that these two inputs do not explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Width and regression filled in: indistinguishable from the rule
&lt;/h2&gt;

&lt;p&gt;If Jev's forecasts are too narrow, does widening them catch up with the rule? I did not ask Jev again; everything below is computed from its existing answers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jev's center + the rule's width:&lt;/strong&gt; a normal distribution centered on Jev's expected change, with the rule's width (the standard deviation of the rule's errors in past pairs, 32 points), turned into five bin probabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flattening:&lt;/strong&gt; Jev's bin probabilities flattened as &lt;code&gt;p ** (1 / T)&lt;/code&gt; and mixed with guessing (20% each) at weight w. T and w were chosen by splitting the 227 into 10 groups, picking the best on nine and scoring the tenth (every group chose T between 2.7 and 3.2 and w = 0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;... plus the missing regression to the mean:&lt;/strong&gt; the first repair, with (past-pair weight 0.43 − Jev's weight 0.20) × (points above the league mean) added to the center. Neither weight uses any 2026 outcome&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotm37tevf7zh2zheqiap.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotm37tevf7zh2zheqiap.png" alt="Width and regression filled in: indistinguishable from the rule" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keeping Jev's center with the rule's width takes the RPS from 0.180 to 0.162, closing more than half of the 0.031 gap to the rule. Flattening gives 0.168. Neither reaches the rule's 0.149, and the 95% intervals of the differences exclude 0 (width only: +0.014 [+0.004, +0.023]).&lt;/p&gt;

&lt;p&gt;Adding the missing regression to the mean gives 0.1505, a difference from the rule of +0.002 (95% interval [−0.005, +0.008]): indistinguishable. I think the remaining gap was almost all the weak regression to the mean. This repair, though, was thought of after seeing the results, and the repaired forecast is no longer Jev's alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a problem settled by a rule, like ABS challenges, Jev lost clearly to a distance-only formula (Brier 0.113 vs 0.180)&lt;/li&gt;
&lt;li&gt;Asked for next season's wOBA change from anonymized lines, Jev beat guessing and was worth a model fitted on 20 to 50 past examples, but it did not reach the Marcel-style rule&lt;/li&gt;
&lt;li&gt;It gets the direction, and it uses luck (wOBA − xwOBA), a little less than it should&lt;/li&gt;
&lt;li&gt;It underestimates how far hitters move, above all by using too little regression to the mean. Repairing the width and the regression after the fact gave a forecast indistinguishable from the rule (no longer Jev's alone)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the two studies share: &lt;strong&gt;Jev gets the direction, but its probabilities sit too close to the middle&lt;/strong&gt;. Personally, if I start from Jev's answers where data is scarce, the next thing I want to try is filling in regression to the mean and the width from a small amount of data.&lt;/p&gt;

&lt;p&gt;I tested one model version (&lt;code&gt;jev-1.13-20260917&lt;/code&gt;), one way of asking, and MLB hitters from 2025 to 2026 only. Other prompts or other tasks may give different results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Terms&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wOBA: an on-base measure weighting each outcome (walk, single, home run and so on) by its run value; the 2025 league mean was .313&lt;/li&gt;
&lt;li&gt;xwOBA: wOBA with each batted ball replaced by the value expected from its exit velocity and launch angle&lt;/li&gt;
&lt;li&gt;RPS: score for ordered-bin forecasts; mean squared gap between cumulative forecast and cumulative outcome; lower is better&lt;/li&gt;
&lt;li&gt;Brier score: score for yes/no probabilities; mean squared gap between probability and the 0/1 outcome; lower is better&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pre-registered vs post-hoc&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pre-registered: how hitters were selected, the bins, the comparators, the learning curve (models fitted on n examples) and the main readings&lt;/li&gt;
&lt;li&gt;The registered readings included a rule like "if the catch-up point is 50 examples, then ...", but the "worse than the Marcel-style rule" rule applied first, so the catch-up point is reported descriptively&lt;/li&gt;
&lt;li&gt;Post-hoc: the bin shares, the scatter, the named hitters, the regression weights, the width and regression repairs, and the ABS breakdown by distance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Caveats&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Without 17 near-outlier lines (any input more than 2.5 standard deviations from the mean), the difference from guessing has an interval crossing 0 ([−0.024, +0.005])&lt;/li&gt;
&lt;li&gt;Rounding does not hide stars like Judge, so Jev may have recognized players. I also blended each line with a similar hitter's to hide identity, but blending narrows the actual changes too (standard deviation 0.036 → 0.029), which favors Jev's narrow forecasts. Recall would make Jev worse on blended lines and the narrowing makes it better, and only the sum is observed, so &lt;strong&gt;this check cannot say whether Jev recalls players&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"Jev's expected change" is computed from its bin probabilities and the past bin averages; Jev did not state it&lt;/li&gt;
&lt;li&gt;The weight comparison uses different samples (Jev's 227 forecasts vs 1,620 past actual changes). Fitting the 227 hitters' actual 2026 changes gives 0.52 for regression and 0.41 for luck, the same pattern&lt;/li&gt;
&lt;li&gt;The ABS comparison uses a zone computed from StatsAPI coordinates, which does not reproduce the ABS zone exactly at the edge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Data and code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hitting: &lt;a href="https://huggingface.co/datasets/yasumorishima/mlb-stats" rel="noopener noreferrer"&gt;yasumorishima/mlb-stats (Hugging Face)&lt;/a&gt;, taken on 2026-09-29, two days after the regular season ended&lt;/li&gt;
&lt;li&gt;ABS challenges: MLB StatsAPI game feeds&lt;/li&gt;
&lt;li&gt;Analysis and charts: &lt;a href="https://github.com/yasumorishima/jev-baseball" rel="noopener noreferrer"&gt;https://github.com/yasumorishima/jev-baseball&lt;/a&gt; (post-hoc analysis in &lt;code&gt;study2-projection/deepdive.py&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Japanese version: &lt;a href="https://qiita.com/ussu_ussu_ussu/items/cc28f711396515c1fb31" rel="noopener noreferrer"&gt;Qiita&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>baseball</category>
      <category>mlb</category>
      <category>machinelearning</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Does a pitch's performance carry over to next season? Whiff rate vs run value on 8,022 MLB pairs</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Tue, 29 Sep 2026 00:08:51 +0000</pubDate>
      <link>https://dev.to/yasumorishima/does-a-pitchs-performance-carry-over-to-next-season-whiff-rate-vs-run-value-on-8022-mlb-pairs-bj</link>
      <guid>https://dev.to/yasumorishima/does-a-pitchs-performance-carry-over-to-next-season-whiff-rate-vs-run-value-on-8022-mlb-pairs-bj</guid>
      <description>&lt;p&gt;While looking at pitch-type stats on Baseball Savant, I started wondering: if a pitch missed a lot of bats one year, does it still miss bats the next year? If a pitch was hard to hit one year, is it still hard to hit the next?&lt;/p&gt;

&lt;p&gt;A good number in one season could mean the pitch is genuinely good, or it could mean it was just a good year, and that probably depends on the metric. So I checked with public MLB data. I have no professional baseball experience; I just aggregated public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data and how the seasons are compared
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pitch-type stats&lt;/strong&gt;: Baseball Savant's pitch arsenal table (pitcher x pitch type x season), 2017–2025&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitch-level data&lt;/strong&gt;: about 5.98 million pitches from Savant's Statcast Search (used later to split run value)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison is simple. &lt;strong&gt;The same pitcher's same pitch type in two consecutive seasons is one pair&lt;/strong&gt;, for example one pitcher's 2023 slider and his 2024 slider, and I take the correlation between season 1 and season 2. Only pairs with 100+ pitches in both seasons are used: &lt;strong&gt;8,022 pairs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Compared as is, the correlation would be inflated just because sliders miss more bats than sinkers no matter who throws them. What I want is "is this slider good among sliders", so I first &lt;strong&gt;subtract that season's average for that pitch type&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In SQL the pairs look like this. It is simplified for the post; the real thing is a dbt model (&lt;code&gt;mart_scouting_reliability&lt;/code&gt;) that also leaves out the season in progress, among other things.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pitch_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pitches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;whiff_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;whiff_rate&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whiff_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pitch_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pitch_arsenal&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;pitches&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;least&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pitches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pitches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;min_pitches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;w1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;w2&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;player_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;player_id&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pitch_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pitch_type&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;season&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;season&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small samples are noisy, so the pairs are split into four bands by &lt;strong&gt;the smaller of the two seasons' pitch counts&lt;/strong&gt;, and &lt;code&gt;corr(w1, w2)&lt;/code&gt; is computed per band.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whiff rate carries over; run value does not
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8m6lhorf26boutum7gg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8m6lhorf26boutum7gg.png" alt="Whiff rate carries over; run value does not" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every metric carries over more with more pitches, but &lt;strong&gt;the gap between metrics is larger than the gap between sample sizes&lt;/strong&gt;. Whiff rate with only 100–199 pitches (0.53) carries over more than run value with 800+ (0.35).&lt;/p&gt;

&lt;p&gt;For the 1,962 pairs with 400+ pitches in both seasons, the year-to-year correlation is 0.70 for whiff rate and 0.27 for run value. This is what that difference looks like, one dot per pair:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv095x1zisasducjzm51e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv095x1zisasducjzm51e.png" alt="Correlation 0.70 vs 0.27" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For whiff rate, most pitches that were above average in season 1 are above average again in season 2, and the dots form a rising band. For run value, a pitch above average in season 1 can land almost anywhere in season 2; the cloud is nearly round.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often the top pitches stay on top
&lt;/h2&gt;

&lt;p&gt;Correlations are hard to picture, so I also counted it another way: rank each season's pitches (after subtracting the pitch-type average) and see how many of the top 20% are still in the top 20% next season. If the two seasons were unrelated, it would be about 20%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhf4ltjtseues62vn13l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuhf4ltjtseues62vn13l.png" alt="Share of top-20% pitches still top 20% next season" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With 400+ pitches in both seasons, about one in three top-20% run value pitches stayed in the top 20%, and 36% dropped to the bottom half (about 50% if unrelated). For whiff rate, only 12% dropped to the bottom half.&lt;/p&gt;

&lt;h2&gt;
  
  
  At 500 pitches, the correlation with next season is 0.68 for whiff rate and 0.20 for run value
&lt;/h2&gt;

&lt;p&gt;If one season's number is "the pitch's true level + noise", then with n pitches the true level is roughly &lt;code&gt;n / (n + k)&lt;/code&gt; of it, where k is the pitch count at which it is half signal, half noise. "Signal" here just means the part that is not chance.&lt;/p&gt;

&lt;p&gt;The true level itself also changes a little from year to year. If c (at most 1) is how stable it is, the year-to-year correlation is c times the geometric mean of the two seasons' signal shares.&lt;/p&gt;

&lt;p&gt;A reader suggested this model in a comment on this post, and later suggested reporting the correlation at a fixed pitch count rather than k. I sorted the 8,022 pairs by pitch count into 12 equal-size groups and searched for the c and k that best fit each group's correlation, using the actual pitch counts of the pairs in the group (weighted by group size). From those I computed the correlation when both seasons have the same n pitches, &lt;code&gt;c * n / (n + k)&lt;/code&gt;:&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="c1"&gt;# expected correlation for one group of pairs with pitch counts n1, n2
&lt;/span&gt;&lt;span class="n"&gt;r_pred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&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;mean&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="c1"&gt;# correlation when both seasons have n pitches
&lt;/span&gt;&lt;span class="n"&gt;r_n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexvs1nz6a4yapz09wfvo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexvs1nz6a4yapz09wfvo.png" alt="Correlation with next season at 500 pitches" width="799" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whiff rate: &lt;strong&gt;0.68&lt;/strong&gt; (95% interval from 200 resamples of pitchers: 0.65–0.70)&lt;/li&gt;
&lt;li&gt;xwOBA allowed: 0.41 (0.38–0.44)&lt;/li&gt;
&lt;li&gt;Run value: &lt;strong&gt;0.20&lt;/strong&gt; (0.18–0.23)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;500 pitches is close to one season of a reliever's main pitch or a starter's second pitch (2021–2025 medians: 463 for the most-used pitch of relievers with 40+ innings, 613 for the second pitch of starters with 100+ innings). A starter's main pitch over a full season is about 1,100 pitches (median 1,086.5 for the most-used pitch of the 316 pitcher-seasons with 2,400+ pitches in 2021–2025). Even there, run value reaches only 0.35 (0.27–0.39), about half of whiff rate at 500, with twice the pitches.&lt;/p&gt;

&lt;p&gt;In terms of k, the pitch count at which half of a season is signal: about 100 for whiff rate (76–138) and about 300 for xwOBA allowed (176–498). Run value's fit gives about 1,800, but the interval runs from 455 to 2,212, and in 92 of the 200 resamples c sat at its cap of 1. Within the pitch counts in this data (at most about 2,300 for one pitch in one season), c and k cannot be told apart well. The correlation at a fixed pitch count does not have that problem: any (c, k) pair that fits the data traces nearly the same curve within this range, so its interval is much narrower than k's (0.18–0.23 at 500 pitches, 0.27–0.39 at about 1,100). An earlier version of this post led with k and said run value needs "at least 600" pitches; recomputed with 200 resamples, the lower end is 455.&lt;/p&gt;

&lt;p&gt;The comment put run value's k at about 700. The bands use the smaller of the two seasons' pitch counts, so the other season had more pitches than that; fitting with both seasons' counts gives a larger k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting run value into parts
&lt;/h2&gt;

&lt;p&gt;Why does run value carry over so little? I went down to pitch-level data.&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://www.mlb.com/glossary/statcast/run-value" rel="noopener noreferrer"&gt;MLB glossary&lt;/a&gt;, run value adds up, pitch by pitch, how much each result (ball, strike, hit, out and so on) moved the expected runs. Pitch-level data carries that value as &lt;code&gt;delta_run_exp&lt;/code&gt; (positive favors the batter). Summing it by pitcher, pitch type and season, flipping the sign and rounding to an integer &lt;strong&gt;matches Savant's published pitch-type run value on 99.2–99.7% of rows in every season&lt;/strong&gt;. The rest differ by 1, and all of them have a fractional part near 0.5, where the rounding rule decides.&lt;/p&gt;

&lt;p&gt;I then split each pitch's value into five parts that add back exactly to run value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mid-PA pitches&lt;/strong&gt;: pitches that did not end the plate appearance (balls, strikes, fouls), the value of the count change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K / BB / HBP&lt;/strong&gt;: plate appearances ending in a strikeout, walk or hit-by-pitch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batted-ball quality&lt;/strong&gt;: for balls in play, the value expected from exit velocity and launch angle (xwOBA) and the count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batted-ball luck&lt;/strong&gt;: for balls in play, actual minus expected: the same contact can be an out at a fielder or a hit through a gap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base/out situation&lt;/strong&gt;: the same count and result is worth different amounts with different runners and outs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first four use values averaged over runners and outs: the mean over all pitches with the same season, count and result, and for batted-ball quality, the mean over batted balls with the same season, count and xwOBA (in 0.05 steps). "Result" is the plate appearance outcome (strikeout, single and so on) for a pitch that ended it, and the pitch call (ball, swinging strike and so on) otherwise.&lt;/p&gt;

&lt;p&gt;The core of it looks like this (simplified: it leaves out batted balls without an xwOBA, the final sign flip to the pitcher's side and so on):&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="c1"&gt;# mean by season, count and result = value averaged over runners and outs
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;season&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strikes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;delta_run_exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# for batted balls, the mean by season, count and xwOBA step is the "expected" value
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cn_exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;bip&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;season&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strikes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xwoba_bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;midpa&lt;/span&gt;&lt;span class="sh"&gt;"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;end_of_pa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn&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="c1"&gt;# mid-PA pitches
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pa_nonbip&lt;/span&gt;&lt;span class="sh"&gt;"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end_of_pa&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;bip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn&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="c1"&gt;# K / BB / HBP
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bip_exp&lt;/span&gt;&lt;span class="sh"&gt;"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn_exp&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="c1"&gt;# batted-ball quality
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bip_luck&lt;/span&gt;&lt;span class="sh"&gt;"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn_exp&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="c1"&gt;# batted-ball luck
&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;situation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delta_run_exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cn&lt;/span&gt;                 &lt;span class="c1"&gt;# base/out situation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One pitch at a time
&lt;/h3&gt;

&lt;p&gt;To see what the split shows, I picked two top run value pitches of 2021 whose makeup differs clearly, and put them next to 2022. These are season totals in runs, not relative to the pitch-type average.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqmt9kj7rhesja2pd118u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqmt9kj7rhesja2pd118u.png" alt="Two top pitches of 2021, built very differently" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adam Wainwright's sinker: +13 of its +20 runs in 2021 were batted-ball luck. Its whiff rate was 9.8%, below the 2021 sinker average (15.5%). In 2022 luck was still positive (+11), but batted-ball quality fell to −18, and the total was 0.&lt;/p&gt;

&lt;p&gt;Corbin Burnes's cutter: in 2021 batted-ball luck was negative (−7), and strikeouts/walks plus mid-PA pitches alone were worth +34. In 2022 those two fell to +26, but luck swung to +11 and batted-ball quality was −10, so the total was +27.&lt;/p&gt;

&lt;p&gt;For one pitch in one season, even the parts that rarely carry over sometimes do and sometimes don't. Two hand-picked examples prove nothing, so here are all the pitches.&lt;/p&gt;

&lt;h3&gt;
  
  
  All pitches
&lt;/h3&gt;

&lt;p&gt;Pairs with 400+ pitches in both seasons (1,962 pairs), per 100 pitches, after subtracting the pitch-type average.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2eukfr2klwaq96smqjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2eukfr2klwaq96smqjd.png" alt="Run value breakdown" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each part's share of one season's run value variation (covariance of the part with run value divided by the variance of run value; the five add to 100%). Batted-ball luck is 27% and base/out situation 4%, about 30% together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr18pvilx2hs6k1rwu152.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr18pvilx2hs6k1rwu152.png" alt="Batted-ball luck and situation barely carry over" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Year-to-year correlation of each part alone. Batted-ball luck (0.06) and situation (−0.03) barely carry over; K / BB / HBP (0.57) and mid-PA pitches (0.60) carry over well. Those two are only about 30% of one season's variation, but about 70% of the "high this year, high next year" link comes from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropping luck and situation predicts next season better
&lt;/h2&gt;

&lt;p&gt;So I tried predicting next season's run value from the three parts that remain after dropping luck and situation. The prediction was fit on pairs whose second season is 2021 or earlier and checked on pairs whose second season is 2022 or later, so it is tested on years it did not see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5y6w9b17u5hvuo8ybw0p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5y6w9b17u5hvuo8ybw0p.png" alt="Dropping luck and situation predicts next season better" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Just adding up the three parts correlates better with next season than this season's run value itself (0.29 → 0.35). Resampling pitchers 2,000 times, the 95% interval of that difference is 0.03 to 0.10 and does not include 0. Pairs with 100–399 pitches go the same way (0.12 → 0.18), though both are low.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whiff rate carries over well: the correlation with next season is 0.68 at 500 pitches, and about 100 pitches are enough for half of a season's number to be signal&lt;/li&gt;
&lt;li&gt;Run value carries over little in one season: 0.20 at 500 pitches, and 0.35 even for a starter's main pitch over a season (about 1,100)&lt;/li&gt;
&lt;li&gt;About 30% of one season's run value variation is batted-ball luck and base/out situation, which barely carry over&lt;/li&gt;
&lt;li&gt;Adding up the other three parts predicts next season's run value better (0.29 → 0.35)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My personal take: when looking at one season of run value, it seems useful to look at whiff rate and strikeouts/walks alongside it to think about the next season.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Terms&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whiff rate: share of swings that miss&lt;/li&gt;
&lt;li&gt;xwOBA allowed: the value of plate appearances ending on that pitch, with batted balls replaced by their expected value from exit velocity and launch angle&lt;/li&gt;
&lt;li&gt;Run value: the sum, pitch by pitch, of how much each pitch moved the expected runs (positive favors the pitcher)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Caveats&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The correlations mix random noise and real change in the pitch (a new grip, lost velocity and so on)&lt;/li&gt;
&lt;li&gt;Bands use the smaller of the two seasons' pitch counts, so a band also holds pairs where one season had many more pitches&lt;/li&gt;
&lt;li&gt;Pairs touching the 60-game 2020 season are included&lt;/li&gt;
&lt;li&gt;Pitchers who stopped pitching (or threw fewer than 100 of that pitch) after a bad year form no pair. That removes low first-season values, so the correlations may be slightly low&lt;/li&gt;
&lt;li&gt;Savant's pitch arsenal table counts knuckle curves and slow curves as curveballs, and the pitch-level data follows it&lt;/li&gt;
&lt;li&gt;Batted-ball luck is only actual minus expected from exit velocity, launch angle and count; defense and park are not separated out&lt;/li&gt;
&lt;li&gt;The five-part split is my own choice; for example, whether the count is used in the batted-ball expectation moves the line between quality and luck a little&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The correlations are built with dbt, with a test that computes the same table another way. The run value split is computed with pandas from pitch-level data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yasumorishima/mlb-data-pipeline" rel="noopener noreferrer"&gt;https://github.com/yasumorishima/mlb-data-pipeline&lt;/a&gt;&lt;/p&gt;

</description>
      <category>baseball</category>
      <category>datascience</category>
      <category>dbt</category>
      <category>duckdb</category>
    </item>
    <item>
      <title>ABS Challenges from Triple-A to MLB: Catchers Win Most, and Batters Who Don't Chase Win More</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Mon, 28 Sep 2026 02:11:16 +0000</pubDate>
      <link>https://dev.to/yasumorishima/abs-challenges-from-triple-a-to-mlb-catchers-win-most-and-batters-who-dont-chase-win-more-499a</link>
      <guid>https://dev.to/yasumorishima/abs-challenges-from-triple-a-to-mlb-catchers-win-most-and-batters-who-dont-chase-win-more-499a</guid>
      <description>&lt;p&gt;In 2026 MLB started the ABS challenge, where a player can dispute a ball/strike call. Watching players tap their helmet or cap on broadcasts, I got curious: who wins these challenges, and where does the gap between good and bad challengers come from?&lt;/p&gt;

&lt;p&gt;Triple-A used the same system a year earlier, in 2025, so there are records from before players reached MLB. With the regular season over, I looked at public data from Baseball Savant and MLB StatsAPI. I have no experience working in baseball; I just tallied up public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How ABS challenges work
&lt;/h2&gt;

&lt;p&gt;According to &lt;a href="https://www.mlb.com/news/abs-challenge-system-mlb-2026" rel="noopener noreferrer"&gt;MLB's announcement&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The plate umpire still calls every pitch. &lt;strong&gt;Only the batter, pitcher or catcher&lt;/strong&gt; can challenge, and must do so immediately&lt;/li&gt;
&lt;li&gt;Each team has &lt;strong&gt;two&lt;/strong&gt; challenges per game; a failed challenge uses one up (a successful one does not)&lt;/li&gt;
&lt;li&gt;Hawk-Eye tracking decides. The zone top is &lt;strong&gt;53.5%&lt;/strong&gt; of the player's height (without cleats), the bottom &lt;strong&gt;27%&lt;/strong&gt;, measured &lt;strong&gt;over the middle of the plate&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A challenge wins (the call is overturned) when the tracked pitch was on the other side of the call.&lt;/p&gt;

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

&lt;p&gt;Two sources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Baseball Savant's ABS challenge boards (per player)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Savant's &lt;a href="https://baseballsavant.mlb.com/leaderboard/abs-challenges" rel="noopener noreferrer"&gt;ABS Challenges&lt;/a&gt; board lists each player's challenges and overturns, plus Savant's expected values: how many times an average player would challenge, and win, given the same opportunities as this player. The page's CSV export has no player id, so I added a function to my package &lt;a href="https://pypi.org/project/savant-extras/" rel="noopener noreferrer"&gt;savant-extras&lt;/a&gt; that reads the JSON embedded in the page:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;savant_extras&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt;

&lt;span class="c1"&gt;# MLB 2026 regular-season catcher challenges, including players with none
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs_challenges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mlb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;challenge_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;game_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regular&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_challenges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I joined 15 boards (MLB and Triple-A; batters, pitchers and catchers; spring and regular season) on the player id, added season stats and Statcast aggregates (chase rate, framing and so on), and published the result on Kaggle (&lt;a href="https://www.kaggle.com/datasets/yasunorim/mlb-abs-challenges-aaa-2025-to-mlb-2026" rel="noopener noreferrer"&gt;ABS Challenges: Triple-A 2025 to MLB 2026&lt;/a&gt;). The numbers here are from the final version, after the regular season.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. MLB StatsAPI game feeds (pitch by pitch)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To see what kind of pitches were challenged, you need pitch-level data. In StatsAPI game feeds (&lt;code&gt;feed/live&lt;/code&gt;, no key needed), a challenged pitch carries &lt;code&gt;reviewDetails&lt;/code&gt;, so I extracted them from every 2026 regular-season game (2,402 games had a challenge).&lt;/p&gt;

&lt;p&gt;There were three traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pitch's &lt;code&gt;details.call.description&lt;/code&gt; is the call &lt;strong&gt;after&lt;/strong&gt; the review. The original call comes from who challenged (the batting side challenges strikes, the fielding side challenges balls)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;count&lt;/code&gt; on a pitch is the count &lt;strong&gt;after&lt;/strong&gt; that pitch. The count before it comes from the previous pitch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the final call ends the plate appearance (strikeout or walk), &lt;code&gt;reviewDetails&lt;/code&gt; sits on the play, not on the pitch.&lt;/strong&gt; Reading only the pitch misses all of those&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I missed the third one at first, and found it when a breakdown by count said batters' challenges with two strikes won 100% of the time, which cannot be right: every failed challenge that would have ended in a strikeout was missing. Picking up both places in jq (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.liveData.plays.allPlays[] as $p
| ($p.playEvents) as $ev
| (
    # the plate appearance went on: on the pitch
    ( range(0; $ev | length) as $i | select($ev[$i].reviewDetails.reviewType == "MJ")
      | {i: $i, rv: $ev[$i].reviewDetails} ),
    # the plate appearance ended: on the play; the challenged pitch is the last one
    ( select($p.reviewDetails.reviewType == "MJ")
      | ([range(0; $ev | length) | select($ev[.].isPitch == true)] | last) as $li
      | {i: $li, rv: $p.reviewDetails} )
  )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked the counts against Savant's board totals: 10,557 regular-season challenges, matching exactly, and by challenger 5,612 catcher (Savant 5,613), 4,766 batter (4,765) and 179 pitcher (179). Before fixing the third trap, 2,661 challenges (25%) were missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  At every level, catchers win the most challenges
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ojxgha85i4j1e7bwxjo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ojxgha85i4j1e7bwxjo.png" alt="At every level, catchers win the most challenges" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the 2026 MLB regular season, catchers won 58.6% of 5,613 challenges, batters 48.9% of 4,765 and pitchers 39.7% of 179. Triple-A has the same order, with a 13-point gap between catchers and batters in 2026.&lt;/p&gt;

&lt;p&gt;Pitchers rarely challenge; on the fielding side it is almost entirely the catcher's job.&lt;/p&gt;

&lt;p&gt;Triple-A catchers went from 53.7% in 2025 to 57.9% in 2026. Batters stayed at 45% both years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do catchers win more?
&lt;/h2&gt;

&lt;p&gt;Catchers win 10 points more often than batters. Is it because they judge the calls better? I looked at what kind of pitches were challenged.&lt;/p&gt;

&lt;p&gt;From the StatsAPI coordinates I computed how far the ball's surface was from the zone edge, and split challenges into three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Call looks clearly wrong:&lt;/strong&gt; at least an inch on the other side of the call (for example, a ball call on a pitch an inch or more inside the zone)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Within an inch of the edge&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call looks right:&lt;/strong&gt; at least an inch on the called side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is my own zone, and it differs from the ABS call near the edge (see the appendix). But pitches an inch or more from the edge were called "as they look" about 98% of the time or more, for catchers and batters alike, so the two outer groups are largely reliable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp69e2eh6mi6nsuzcj3mw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp69e2eh6mi6nsuzcj3mw.png" alt="Catchers pick pitches whose call looks clearly wrong" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;38% of catchers' challenges were on pitches whose call looks clearly wrong, against 21% of batters'. The other way round, 16% of catchers' challenges were on calls that look right, against 36% of batters'.&lt;/p&gt;

&lt;p&gt;I also swapped only the mix of pitches, keeping each group's win rate. If batters chose pitches in the catchers' mix, they would win 67.4%, more than catchers actually do (58.6%). If catchers chose in the batters' mix, they would win 40.7%. Either way, &lt;strong&gt;the difference in mix alone opens a gap bigger than the actual 10 points&lt;/strong&gt; between catchers and batters. Most of the catchers' edge seems to come from choosing more pitches whose call looks clearly wrong. Catchers receive the ball in their mitt and see its location closest, which may make those pitches easier to pick.&lt;/p&gt;

&lt;p&gt;On the hard pitches within an inch of the edge, batters won 64% and catchers only 46%. That group, though, is where my zone's error matters most (appendix), so I cannot say batters are better at judging the hard ones.&lt;/p&gt;

&lt;p&gt;So why do batters challenge calls that look right? I looked at the situations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fri4ns3h1lvioknh38212.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fri4ns3h1lvioknh38212.png" alt="With two strikes, batters' challenges win less often" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By strikes before the pitch, batters won 59% with no strikes and 40% with two. Catchers won 61%, 62% and 53%, a smaller drop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog6tuvhehigm7p9sio6v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog6tuvhehigm7p9sio6v.png" alt="A third of batters' challenges dispute a called strike three" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A called strike with two strikes is strike three if it stands. 34% of batters' challenges came in that spot (the same 1,619 challenges as the two-strike point above), and 40% won, 13.5 points below their challenges of other strike calls (53%).&lt;/p&gt;

&lt;p&gt;45% of the strike-three challenges were on pitches whose call looks right (32% for other strike calls). When a strikeout is on the line, batters challenge more long shots.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdej7m4xhoqfqcsuxnc92.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdej7m4xhoqfqcsuxnc92.png" alt="Late in the game, challenges win less often" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Late in the game, from the 9th inning on, both catchers and batters won less often. The share of catchers' challenges on calls that look right rose from 13% in innings 1 to 6 to 28% from the 9th on.&lt;/p&gt;

&lt;p&gt;This data alone does not say why. Unused challenges disappear at the end of the game, so teams may spend what is left; or late innings hold more high-leverage spots, where even a long shot is worth a challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Among catchers, the share won ranges from 47% to 81%
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgafiv5jbvywkhwousm1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgafiv5jbvywkhwousm1.png" alt="Among catchers, the share won ranges from 47% to 81%" width="800" height="592"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are the 44 MLB catchers with 60+ challenges in 2026. J.T. Realmuto won 70 of 86 (81%); Francisco Alvarez won 42 of 90 (47%).&lt;/p&gt;

&lt;p&gt;Savant's expected values say how many times an average player would challenge, and win, given the same opportunities. Realmuto's expected challenge count was 138; he made 86, about 40% fewer than an average catcher. He may have saved them for pitches likely to win. Alvarez made 90 against an expected 81, a little more than average. Salvador Perez, second at 46 of 65 (71%), made exactly as many challenges as expected (65).&lt;/p&gt;

&lt;p&gt;With 60 to 90 challenges per catcher, chance is not small. Using the catchers' overall 58.6%, the binomial probability of winning 70 or more of 86 is below 1 in 100,000, and about 0.03% that any of 44 catchers would. Realmuto is hard to explain by chance. Winning 42 or fewer of 90 has a probability of 1.5%, or 49% that at least one of 44 catchers would; Alvarez's low number is well within what chance produces.&lt;/p&gt;

&lt;p&gt;Across the 44, catchers who challenged less than expected tended to win more often (Spearman −0.26, p = 0.09), but the trend is not clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batters who chase less win more challenges
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrcom3gswwn3yfg9xuhl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrcom3gswwn3yfg9xuhl.png" alt="Batters who chase less win more challenges" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I split the 465 MLB batters with 100+ plate appearances in Statcast into thirds by chase rate (swings at pitches outside the zone). From the least to the most chasing, they won 51.3%, 48.0% and 45.7% of their challenges. Among the 182 with 10+ challenges, the rank correlation between chase rate and share won is −0.20 (p = 0.008).&lt;/p&gt;

&lt;p&gt;Do batters with a good eye judge calls better? As with catchers, I split their challenges into the three groups (4,603 challenges from the three thirds):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Challenges of calls that look right: 33% for the least chasing third, 37% in the middle, 39% for the most chasing&lt;/li&gt;
&lt;li&gt;Challenges of calls that look clearly wrong: 22%, 21%, 19%&lt;/li&gt;
&lt;li&gt;Win rates within each group are about the same across the thirds (63% to 64% within an inch of the edge)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the difference is that batters who chase less challenge fewer calls that look right and slightly more that look clearly wrong. There is no clear difference in judging the hard pitches near the edge.&lt;/p&gt;

&lt;p&gt;How &lt;strong&gt;often&lt;/strong&gt; a batter challenges is nearly unrelated to chase rate (Spearman −0.04, 465 batters).&lt;/p&gt;

&lt;p&gt;By name, Taylor Ward, with the lowest chase rate (15%), won 8 of 15 (53%); Ceddanne Rafaela, with the highest (45%), won 6 of 24 (25%).&lt;/p&gt;

&lt;h2&gt;
  
  
  Stealing strikes and winning challenges are different skills
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f7nd38lzvhtpbgpobe7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f7nd38lzvhtpbgpobe7.png" alt="Stealing strikes and winning challenges are different skills" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Catchers can make balls look like strikes: framing. Savant's official framing is MLB-only, so I computed, per catcher from pitch-level Statcast, the share of out-of-zone takes called strikes. That works in Triple-A too.&lt;/p&gt;

&lt;p&gt;As the left panel shows, in MLB 2026 it has a rank correlation of 0.83 with official framing runs (56 catchers), good enough as a stand-in (Triple-A has no official number, so I cannot check the fit there).&lt;/p&gt;

&lt;p&gt;But it is almost unrelated to challenge skill (Savant's challenges vs expected, see the appendix), as the right panel shows: 0.03 in MLB 2026, 0.08 in Triple-A 2026 and 0.14 in Triple-A 2025, none significant. Making a ball look like a strike and spotting a wrong call worth disputing seem to be different skills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries up from Triple-A is how often a player challenges
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x2ive5ymatprmcmg6uk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x2ive5ymatprmcmg6uk.png" alt="What carries up from Triple-A is how often a player challenges" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I compared the same players in Triple-A 2025 and MLB 2026 (63 batters and 51 catchers with 5+ challenges in both):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How often&lt;/strong&gt; they challenge carries over clearly (Spearman 0.37 for batters, 0.45 for catchers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share won&lt;/strong&gt; carries over for batters (0.32, p = 0.01); for catchers it is 0.24 (p = 0.10)&lt;/li&gt;
&lt;li&gt;Savant's &lt;strong&gt;challenges vs expected&lt;/strong&gt; (the net against an average player given the same opportunities) does not: 0.05 for batters and 0.21 for catchers, neither clear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chase rate itself carries over strongly (0.68). As in the previous section, batters who chase less challenge fewer calls that look right, so the carry-over in share won may come from there (I did not check this). For example, Gary Sánchez challenged at 16% in Triple-A 2025 (7 challenges) and 16% in MLB 2026 (37).&lt;/p&gt;

&lt;h2&gt;
  
  
  No clear age difference
&lt;/h2&gt;

&lt;p&gt;Split into four age groups, MLB 2026 batters aged 34 and over won 54.2%, higher than the rest. But that is 34 batters and 334 challenges, and the interval (48.8% to 59.5%) overlaps the other groups. Savant's challenges vs expected per batter is −0.23 for 25 and under, +0.29 for 26 to 29, −0.30 for 30 to 33 and +0.73 for 34 and over, with no ordering by age. There is no clear age effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In this data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At both MLB and Triple-A, catchers win the most challenges (58.6% in MLB 2026, batters 48.9%)&lt;/li&gt;
&lt;li&gt;Most of that gap seems to come from catchers choosing more pitches whose call looks clearly wrong (38% vs 21%). Batters challenge more calls that look right with two strikes, especially on called strike three&lt;/li&gt;
&lt;li&gt;Late in the game, more challenges go to calls that look right, and fewer win&lt;/li&gt;
&lt;li&gt;Catchers differ a lot: Realmuto challenged about 40% less than expected and won 70 of 86&lt;/li&gt;
&lt;li&gt;Batters who chase less win more, because they challenge fewer calls that look right and slightly more that look clearly wrong&lt;/li&gt;
&lt;li&gt;Framing and challenge skill are different things&lt;/li&gt;
&lt;li&gt;What carries from Triple-A to MLB is how often a player challenges; whether skill carries is unclear&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Terms&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Expected values (Savant): how many times an average player would challenge (&lt;code&gt;exp_chal&lt;/code&gt;) and win (&lt;code&gt;exp_chal_gained&lt;/code&gt;) given the same opportunities as this player. They are not computed for the pitches the player chose. "Challenges vs expected" here is Savant's &lt;code&gt;overturns_vs_exp&lt;/code&gt;: the net of overturns and failures against expectation, minus the same for challenges made against the player's side. It mixes how often and how well a player challenges&lt;/li&gt;
&lt;li&gt;Challenge rate: challenges ÷ the opportunities Savant counts for the player (&lt;code&gt;n_total_sample&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Chase rate: share of pitches outside the zone that the batter swung at (Statcast)&lt;/li&gt;
&lt;li&gt;Rank correlation: Spearman's&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Caveats&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Everything here is correlation, not causation&lt;/li&gt;
&lt;li&gt;The "distance from the zone edge" in the pitch-level sections is my own, from StatsAPI coordinates (measured at the front of the plate) and the zone top and bottom in StatsAPI. The zone top and bottom were constant per batter in 2026, but ABS judges over the middle of the plate, so my zone differs from the actual call near the edge. Within an inch of my edge, 64% of batters' and 46% of catchers' challenges were overturned, so the error may lean against catchers. An inch or more from the edge, 97.9% to 99.7% of calls went the way they look&lt;/li&gt;
&lt;li&gt;Individual players' shares swing a lot with few challenges. The named players' numbers are one season's results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Data and code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dataset: &lt;a href="https://www.kaggle.com/datasets/yasunorim/mlb-abs-challenges-aaa-2025-to-mlb-2026" rel="noopener noreferrer"&gt;https://www.kaggle.com/datasets/yasunorim/mlb-abs-challenges-aaa-2025-to-mlb-2026&lt;/a&gt; (DOI: &lt;a href="https://doi.org/10.34740/kaggle/dsv/20077454" rel="noopener noreferrer"&gt;10.34740/kaggle/dsv/20077454&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Notebook: &lt;a href="https://www.kaggle.com/code/yasunorim/abs-challenges-who-wins-them-aaa-to-mlb" rel="noopener noreferrer"&gt;ABS Challenges: Who Wins Them, AAA to MLB&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Build code: &lt;a href="https://github.com/yasumorishima/kaggle-datasets" rel="noopener noreferrer"&gt;https://github.com/yasumorishima/kaggle-datasets&lt;/a&gt; (&lt;code&gt;abs-challenges-dataset/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Sources: Baseball Savant / MLB StatsAPI (MLB Advanced Media)&lt;/li&gt;
&lt;li&gt;Japanese version: &lt;a href="https://qiita.com/ussu_ussu_ussu/items/57c5760b3f9917b3883f" rel="noopener noreferrer"&gt;Qiita&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Rewritten on 2026-09-30 with pitch-level data for the full regular season. An earlier version described Savant's expected values as accounting for the difficulty of the challenged pitches, and said older batters were below expected; Savant's expected values are for an average player given the same opportunities, and that "below expected" figure mixed two denominators.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>baseball</category>
      <category>mlb</category>
      <category>statcast</category>
      <category>datascience</category>
    </item>
    <item>
      <title>M5 StackChan pairing fails with No devices found — the factory firmware is the cause</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:00:55 +0000</pubDate>
      <link>https://dev.to/yasumorishima/m5-stackchan-pairing-fails-with-no-devices-found-the-factory-firmware-is-the-cause-387e</link>
      <guid>https://dev.to/yasumorishima/m5-stackchan-pairing-fails-with-no-devices-found-the-factory-firmware-is-the-cause-387e</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I bought the official M5Stack StackChan (&lt;code&gt;M5STACK-K151&lt;/code&gt;), and the "StackChan World" mobile app refused to finish setup: after selecting the device, it showed &lt;strong&gt;&lt;code&gt;No devices found&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause was &lt;strong&gt;not Bluetooth permissions and not an app bug — the factory firmware was simply too old&lt;/strong&gt;. After flashing the latest official firmware over USB, pairing went through on the first attempt.&lt;/p&gt;

&lt;p&gt;What makes this worth writing about is that the situation is &lt;strong&gt;a closed loop you cannot escape on the device itself&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New firmware arrives via OTA&lt;/li&gt;
&lt;li&gt;OTA needs Wi-Fi&lt;/li&gt;
&lt;li&gt;Wi-Fi setup needs app pairing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pairing fails on the old firmware&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;strong&gt;flashing over USB is the only way out&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment and dates
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;M5StackChan AI Desktop Robot (ESP32-S3) / &lt;code&gt;M5STACK-K151&lt;/code&gt; (official assembled unit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purchased&lt;/td&gt;
&lt;td&gt;2026-07-24 (Switch Science, Japan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup attempted&lt;/td&gt;
&lt;td&gt;2026-07-25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Factory firmware on the unit&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.2.4&lt;/strong&gt; (released 2026-04-20)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest official firmware that day&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.4.4&lt;/strong&gt; (released 2026-07-13)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;Windows 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The dates matter here.&lt;/strong&gt; This product launched in 2026-05 and both the firmware and the app are still being updated frequently. &lt;strong&gt;The firmware version that ships with a unit depends on when you buy it&lt;/strong&gt;, so whether you hit this problem depends on timing. If you buy stock that has been sitting for a few months, you are a good candidate for the same trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The setup flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Power on, pass the servo test&lt;/li&gt;
&lt;li&gt;The device shows a 12-digit ID&lt;/li&gt;
&lt;li&gt;In the app: "Add a new StackChan" → scan for nearby devices&lt;/li&gt;
&lt;li&gt;Pick the entry matching the ID on the device screen&lt;/li&gt;
&lt;li&gt;Set device name → AI agent → Wi-Fi&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 4 immediately produced &lt;code&gt;No devices found&lt;/code&gt;&lt;/strong&gt;, and step 5 was never reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not help
&lt;/h2&gt;

&lt;p&gt;The message talks about devices not being found, so of course I attacked discovery first. None of this changed anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Granting Bluetooth and location permissions to the app (verified from the OS settings, not just the in-app prompt)&lt;/li&gt;
&lt;li&gt;Turning location services on (BLE scanning on Android is entangled with location)&lt;/li&gt;
&lt;li&gt;Force-stopping and restarting the app, rebooting the phone&lt;/li&gt;
&lt;li&gt;Rebooting the device, returning to the ID screen before scanning&lt;/li&gt;
&lt;li&gt;Toggling Bluetooth off and on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I learned the hard way: &lt;strong&gt;do not pair from the OS Bluetooth settings screen.&lt;/strong&gt; If you bond the device at the OS level, it gets registered as a known device and &lt;strong&gt;disappears from the app's "find a new device" list&lt;/strong&gt;. I did exactly this while "just checking", which made the picture even muddier. If you already did it, remove the pairing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The serial log told a completely different story
&lt;/h2&gt;

&lt;p&gt;I stopped guessing about permissions and captured the USB serial log. When you connect the unit to a PC, the CoreS3 (ESP32-S3) native USB shows up as a serial port (&lt;code&gt;VID_303A&lt;/code&gt; / &lt;code&gt;PID_1001&lt;/code&gt;, USB-Serial/JTAG).&lt;/p&gt;

&lt;p&gt;Here is what the log showed while the app was "not finding" anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I (111140) NimBLE: connection established; status=0
I (111160) NimBLE:  peer_ota_addr_type=1 peer_ota_addr=
I (111160) NimBLE: 63:a2:04:c7:fa:5d
[info] [WifiConfigServer] app Connected
I (112260) NimBLE: Stack-Chan characteristic write; conn_handle=1 attr_handle=22
I (112260) NimBLE: Config data received (42 bytes): {"cmd":"handshake","data":"1784964126892"}
I (112420) NimBLE: GATT procedure initiated: notify;
I (112430) NimBLE: notify_tx event; conn_handle=1 attr_handle=22 status=0 is_indication=0
I (112440) NimBLE: Config notification sent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The BLE connection was established&lt;/strong&gt; (&lt;code&gt;status=0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app was connected&lt;/strong&gt; (&lt;code&gt;app Connected&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app sent a handshake&lt;/strong&gt; (&lt;code&gt;{"cmd":"handshake"}&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The device answered&lt;/strong&gt; (&lt;code&gt;Config notification sent&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;And then &lt;strong&gt;nothing more arrives from the app&lt;/strong&gt; — not even a disconnect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;No devices found&lt;/code&gt; did not describe reality. The process was not failing at &lt;strong&gt;discovery&lt;/strong&gt;; it was failing at &lt;strong&gt;verification of the response the device sent back&lt;/strong&gt;. Trusting that message is what sent me down the permissions rabbit hole.&lt;/p&gt;

&lt;p&gt;For confirmation I read the app side too. The firmware, the app, and the server for StackChan are all published in &lt;a href="https://github.com/m5stack/StackChan" rel="noopener noreferrer"&gt;m5stack/StackChan&lt;/a&gt; — the Flutter app source lives in the same repository. The device-selection screen has a 30-second connect timeout and a 20-second verification timeout, with failure messages such as &lt;code&gt;Device did not return encryption data.&lt;/code&gt; and &lt;code&gt;Device verification decryption failed.&lt;/code&gt;, and it registers the device with the server only after verification succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: firmware version, plus a deadlock
&lt;/h2&gt;

&lt;p&gt;The boot log prints the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I (690) app_init: Project name:     stack-chan
I (696) app_init: App version:      1.2.4
I (700) app_init: Compile time:     Apr 15 2026 09:18:33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1.2.4&lt;/strong&gt;, while the latest official build that day was &lt;strong&gt;1.4.4&lt;/strong&gt; — nine versions apart.&lt;/p&gt;

&lt;p&gt;And that is where the loop from the top of this post bites: OTA needs Wi-Fi, Wi-Fi needs pairing, pairing fails on old firmware. &lt;strong&gt;USB is the only remaining path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note that &lt;strong&gt;building the firmware yourself does not help&lt;/strong&gt;. The handshake implementation in the public repository (&lt;code&gt;firmware/main/hal/utils/secret_logic/secret_logic.cpp&lt;/code&gt;) is a stub whose token function returns a fixed string. It is declared as a weak symbol, and the real implementation is linked in at official build time. &lt;strong&gt;Only the official binary can satisfy the app's verification step.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: flash the official firmware over USB, without M5Burner
&lt;/h2&gt;

&lt;p&gt;The official flashing tool is M5Burner (a GUI), but &lt;strong&gt;the firmware distribution API it uses is public&lt;/strong&gt;, so the whole thing can be done from the CLI. No Python required either.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. List the official firmware versions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://m5burner-api.m5stack.com/api/firmware"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.name=="StackChan-UserDemo") | .versions[] | "\(.version)  \(.published_at)  \(.file)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory firmware is named &lt;strong&gt;&lt;code&gt;StackChan-UserDemo&lt;/code&gt;&lt;/strong&gt; (author: M5Stack):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1.2.4   2026-04-20  3c8ffe6be0ca26375d836e10e06e3609.bin
V1.4.3   2026-07-02  fb75fa818e63b7ee6b0d35eba308f386.bin
V1.4.4   2026-07-13  790e3fcde496020aa7f188153b23e6f0.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older versions stay available, so &lt;strong&gt;rolling back is possible too&lt;/strong&gt; — which makes updating a lot less scary.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Download the binary
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"https://m5burner-cdn.m5stack.com/firmware/790e3fcde496020aa7f188153b23e6f0.bin"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; stackchan-v1.4.4.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first byte is &lt;code&gt;e9&lt;/code&gt; (the ESP image magic), it is a merged full image to be written at offset &lt;code&gt;0x0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-An&lt;/span&gt; &lt;span class="nt"&gt;-tx1&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; 1 stackchan-v1.4.4.bin
&lt;span class="c"&gt;#  e9&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Get esptool (no Python needed)
&lt;/h3&gt;

&lt;p&gt;Espressif ships standalone executables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/espressif/esptool/releases/latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.assets[] | select(.name|test("windows-amd64")) | .browser_download_url'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Verify communication with a read-only command first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\esptool.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;COM3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;flash-id&lt;/span&gt;&lt;span class="w"&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 plaintext"&gt;&lt;code&gt;Detecting chip type... ESP32-S3
Chip type:          ESP32-S3 (QFN56) (revision v0.2)
USB mode:           USB-Serial/JTAG
Detected flash size: 16MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No manual download mode was necessary&lt;/strong&gt; — esptool resets the chip through USB-Serial/JTAG. If it cannot connect, hold the reset button for about 2 seconds and release it once the internal green LED lights up.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Flash
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\esptool.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;COM3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;write-flash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0x0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;stackchan-v1.4.4.bin&lt;/span&gt;&lt;span class="w"&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 plaintext"&gt;&lt;code&gt;Wrote 12783792 bytes (3185393 compressed) at 0x00000000 in 47.7 seconds
Verifying written data...
Hash of data verified.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 50 seconds for just under 12.8 MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Confirm the version
&lt;/h3&gt;

&lt;p&gt;Look for &lt;code&gt;app_init: App version: 1.4.4&lt;/code&gt; in the boot log. With that in place, pairing succeeded on the first try, and I went straight through AI agent setup, Wi-Fi setup, and an actual voice conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other traps worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Opening the serial port reboots the device
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You cannot capture logs and operate the device at the same time.&lt;/strong&gt; Opening the port in the middle of the setup wizard sends you back to the start (it did for me). The flip side is useful: if you want the boot log from the very first line, open the port and then trigger a reset.&lt;/p&gt;

&lt;h3&gt;
  
  
  There is no interactive serial console
&lt;/h3&gt;

&lt;p&gt;Sending commands produces no response, and the write itself blocks. Treat the serial port as read-only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not power both USB-C ports at once
&lt;/h3&gt;

&lt;p&gt;The unit has a USB-C port on the CoreS3 and another on the heel (base). 5V fed into the CoreS3 side is routed to the base through the M-BUS &lt;code&gt;BUS_5V&lt;/code&gt; line, so &lt;strong&gt;powering both ports puts two supplies on the same rail&lt;/strong&gt;. The official documentation's "try powering from both ports" means one at a time. The heel port is the recommended one for power and flashing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Servo zero positions survive a full flash erase
&lt;/h3&gt;

&lt;p&gt;I backed up NVS before flashing, but the same zero positions were read back afterwards (&lt;code&gt;[ScsServo] id: 1 get zero pos: 460 from settings&lt;/code&gt;). &lt;strong&gt;Those values appear to live in the servos themselves&lt;/strong&gt;, so a full-flash rewrite does not lose them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A separate warning about charging
&lt;/h2&gt;

&lt;p&gt;The firmware sets the AXP2101 charge current to &lt;strong&gt;700mA&lt;/strong&gt; at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;setChargerConstantCurr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XPOWERS_AXP2101_CHG_CUR_700MA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ESP32-S3 enumerates as a USB 2.0 device, so &lt;strong&gt;a PC USB port gives you 500mA at most&lt;/strong&gt;. Combined with the system draw (LCD, camera, servos, radio) that exceeds the input limit, and &lt;strong&gt;charging never starts from a PC port&lt;/strong&gt;. Use a USB charger with headroom at 5V.&lt;/p&gt;

&lt;p&gt;If the servos do not report their current position (&lt;code&gt;[ScsServo] ignore invalid current pos: -1&lt;/code&gt;), read that as another sign of insufficient power.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;No devices found&lt;/code&gt; &lt;strong&gt;did not describe what was happening&lt;/strong&gt;. The BLE connection and the handshake both succeeded; the failure was in response verification&lt;/li&gt;
&lt;li&gt;The real cause was &lt;strong&gt;old factory firmware&lt;/strong&gt;, fixed by flashing the latest official build over USB&lt;/li&gt;
&lt;li&gt;OTA ↔ Wi-Fi ↔ pairing form a loop, so &lt;strong&gt;USB flashing is the only exit&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not over-trust the error string — capture the log.&lt;/strong&gt; Every minute I spent re-checking permissions would have been saved by one serial capture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scripts and the full write-up are in the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/yasumorishima/stackchan-lab" rel="noopener noreferrer"&gt;https://github.com/yasumorishima/stackchan-lab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a href="https://docs.m5stack.com/en/stackchan" rel="noopener noreferrer"&gt;https://docs.m5stack.com/en/stackchan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Firmware / app / server source: &lt;a href="https://github.com/m5stack/StackChan" rel="noopener noreferrer"&gt;https://github.com/m5stack/StackChan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>m5stack</category>
      <category>esp32</category>
      <category>iot</category>
      <category>ble</category>
    </item>
    <item>
      <title>M5 StackChan pairing fails with \"No devices found\"? The factory firmware is the cause</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:48:29 +0000</pubDate>
      <link>https://dev.to/yasumorishima/m5-stackchan-pairing-fails-with-no-devices-found-the-factory-firmware-is-the-cause-1od8</link>
      <guid>https://dev.to/yasumorishima/m5-stackchan-pairing-fails-with-no-devices-found-the-factory-firmware-is-the-cause-1od8</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I bought the official M5Stack StackChan (&lt;code&gt;M5STACK-K151&lt;/code&gt;), and the "StackChan World" mobile app refused to finish setup: after selecting the device, it showed &lt;strong&gt;&lt;code&gt;No devices found&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause was &lt;strong&gt;not Bluetooth permissions and not an app bug — the factory firmware was simply too old&lt;/strong&gt;. After flashing the latest official firmware over USB, pairing went through on the first attempt.&lt;/p&gt;

&lt;p&gt;What makes this worth writing about is that the situation is &lt;strong&gt;a closed loop you cannot escape on the device itself&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New firmware arrives via OTA&lt;/li&gt;
&lt;li&gt;OTA needs Wi-Fi&lt;/li&gt;
&lt;li&gt;Wi-Fi setup needs app pairing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pairing fails on the old firmware&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;strong&gt;flashing over USB is the only way out&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment and dates
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;M5StackChan AI Desktop Robot (ESP32-S3) / &lt;code&gt;M5STACK-K151&lt;/code&gt; (official assembled unit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purchased&lt;/td&gt;
&lt;td&gt;2026-07-24 (Switch Science, Japan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup attempted&lt;/td&gt;
&lt;td&gt;2026-07-25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Factory firmware on the unit&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.2.4&lt;/strong&gt; (released 2026-04-20)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest official firmware that day&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.4.4&lt;/strong&gt; (released 2026-07-13)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;Windows 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The dates matter here.&lt;/strong&gt; This product launched in 2026-05 and both the firmware and the app are still being updated frequently. &lt;strong&gt;The firmware version that ships with a unit depends on when you buy it&lt;/strong&gt;, so whether you hit this problem depends on timing. If you buy stock that has been sitting for a few months, you are a good candidate for the same trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The setup flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Power on, pass the servo test&lt;/li&gt;
&lt;li&gt;The device shows a 12-digit ID&lt;/li&gt;
&lt;li&gt;In the app: "Add a new StackChan" → scan for nearby devices&lt;/li&gt;
&lt;li&gt;Pick the entry matching the ID on the device screen&lt;/li&gt;
&lt;li&gt;Set device name → AI agent → Wi-Fi&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 4 immediately produced &lt;code&gt;No devices found&lt;/code&gt;&lt;/strong&gt;, and step 5 was never reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not help
&lt;/h2&gt;

&lt;p&gt;The message talks about devices not being found, so of course I attacked discovery first. None of this changed anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Granting Bluetooth and location permissions to the app (verified from the OS settings, not just the in-app prompt)&lt;/li&gt;
&lt;li&gt;Turning location services on (BLE scanning on Android is entangled with location)&lt;/li&gt;
&lt;li&gt;Force-stopping and restarting the app, rebooting the phone&lt;/li&gt;
&lt;li&gt;Rebooting the device, returning to the ID screen before scanning&lt;/li&gt;
&lt;li&gt;Toggling Bluetooth off and on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I learned the hard way: &lt;strong&gt;do not pair from the OS Bluetooth settings screen.&lt;/strong&gt; If you bond the device at the OS level, it gets registered as a known device and &lt;strong&gt;disappears from the app's "find a new device" list&lt;/strong&gt;. I did exactly this while "just checking", which made the picture even muddier. If you already did it, remove the pairing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The serial log told a completely different story
&lt;/h2&gt;

&lt;p&gt;I stopped guessing about permissions and captured the USB serial log. When you connect the unit to a PC, the CoreS3 (ESP32-S3) native USB shows up as a serial port (&lt;code&gt;VID_303A&lt;/code&gt; / &lt;code&gt;PID_1001&lt;/code&gt;, USB-Serial/JTAG).&lt;/p&gt;

&lt;p&gt;Here is what the log showed while the app was "not finding" anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;I (111140) NimBLE: connection established;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="go"&gt;I (111160) NimBLE:  peer_ota_addr_type=1 peer_ota_addr=
I (111160) NimBLE: 63:a2:04:c7:fa:5d
[info] [WifiConfigServer] app Connected
&lt;/span&gt;&lt;span class="gp"&gt;I (112260) NimBLE: Stack-Chan characteristic write;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;conn_handle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;attr_handle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;22
&lt;span class="go"&gt;I (112260) NimBLE: Config data received (42 bytes): {"cmd":"handshake","data":"1784964126892"}
&lt;/span&gt;&lt;span class="gp"&gt;I (112420) NimBLE: GATT procedure initiated: notify;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;I (112430) NimBLE: notify_tx event;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;conn_handle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;attr_handle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;22 &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;is_indication&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="go"&gt;I (112440) NimBLE: Config notification sent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The BLE connection was established&lt;/strong&gt; (&lt;code&gt;status=0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app was connected&lt;/strong&gt; (&lt;code&gt;app Connected&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app sent a handshake&lt;/strong&gt; (&lt;code&gt;{"cmd":"handshake"}&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The device answered&lt;/strong&gt; (&lt;code&gt;Config notification sent&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;And then &lt;strong&gt;nothing more arrives from the app&lt;/strong&gt; — not even a disconnect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;No devices found&lt;/code&gt; did not describe reality. The process was not failing at &lt;strong&gt;discovery&lt;/strong&gt;; it was failing at &lt;strong&gt;verification of the response the device sent back&lt;/strong&gt;. Trusting that message is what sent me down the permissions rabbit hole.&lt;/p&gt;

&lt;p&gt;For confirmation I read the app side too. The firmware, the app, and the server for StackChan are all published in &lt;a href="https://github.com/m5stack/StackChan" rel="noopener noreferrer"&gt;m5stack/StackChan&lt;/a&gt; — the Flutter app source lives in the same repository. The device-selection screen has a 30-second connect timeout and a 20-second verification timeout, with failure messages such as &lt;code&gt;Device did not return encryption data.&lt;/code&gt; and &lt;code&gt;Device verification decryption failed.&lt;/code&gt;, and it registers the device with the server only after verification succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: firmware version, plus a deadlock
&lt;/h2&gt;

&lt;p&gt;The boot log prints the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;I (690) app_init: Project name:     stack-chan
I (696) app_init: App version:      1.2.4
I (700) app_init: Compile time:     Apr 15 2026 09:18:33
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1.2.4&lt;/strong&gt;, while the latest official build that day was &lt;strong&gt;1.4.4&lt;/strong&gt; — nine versions apart.&lt;/p&gt;

&lt;p&gt;And that is where the loop from the top of this post bites: OTA needs Wi-Fi, Wi-Fi needs pairing, pairing fails on old firmware. &lt;strong&gt;USB is the only remaining path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note that &lt;strong&gt;building the firmware yourself does not help&lt;/strong&gt;. The handshake implementation in the public repository (&lt;code&gt;firmware/main/hal/utils/secret_logic/secret_logic.cpp&lt;/code&gt;) is a stub whose token function returns a fixed string. It is declared as a weak symbol, and the real implementation is linked in at official build time. &lt;strong&gt;Only the official binary can satisfy the app's verification step.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: flash the official firmware over USB, without M5Burner
&lt;/h2&gt;

&lt;p&gt;The official flashing tool is M5Burner (a GUI), but &lt;strong&gt;the firmware distribution API it uses is public&lt;/strong&gt;, so the whole thing can be done from the CLI. No Python required either.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. List the official firmware versions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://m5burner-api.m5stack.com/api/firmware"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.name=="StackChan-UserDemo") | .versions[] | "\(.version)  \(.published_at)  \(.file)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory firmware is named &lt;strong&gt;&lt;code&gt;StackChan-UserDemo&lt;/code&gt;&lt;/strong&gt; (author: M5Stack):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1.2.4   2026-04-20  3c8ffe6be0ca26375d836e10e06e3609.bin
V1.4.3   2026-07-02  fb75fa818e63b7ee6b0d35eba308f386.bin
V1.4.4   2026-07-13  790e3fcde496020aa7f188153b23e6f0.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older versions stay available, so &lt;strong&gt;rolling back is possible too&lt;/strong&gt; — which makes updating a lot less scary.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Download the binary
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"https://m5burner-cdn.m5stack.com/firmware/790e3fcde496020aa7f188153b23e6f0.bin"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; stackchan-v1.4.4.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first byte is &lt;code&gt;e9&lt;/code&gt; (the ESP image magic), it is a merged full image to be written at offset &lt;code&gt;0x0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-An&lt;/span&gt; &lt;span class="nt"&gt;-tx1&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; 1 stackchan-v1.4.4.bin
&lt;span class="c"&gt;#  e9&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Get esptool (no Python needed)
&lt;/h3&gt;

&lt;p&gt;Espressif ships standalone executables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/espressif/esptool/releases/latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.assets[] | select(.name|test("windows-amd64")) | .browser_download_url'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Verify communication with a read-only command first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\esptool.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;COM3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;flash-id&lt;/span&gt;&lt;span class="w"&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 console"&gt;&lt;code&gt;&lt;span class="go"&gt;Detecting chip type... ESP32-S3
Chip type:          ESP32-S3 (QFN56) (revision v0.2)
USB mode:           USB-Serial/JTAG
Detected flash size: 16MB
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No manual download mode was necessary&lt;/strong&gt; — esptool resets the chip through USB-Serial/JTAG. If it cannot connect, hold the reset button for about 2 seconds and release it once the internal green LED lights up.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Flash
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\esptool.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;COM3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;write-flash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0x0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;stackchan-v1.4.4.bin&lt;/span&gt;&lt;span class="w"&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 console"&gt;&lt;code&gt;&lt;span class="go"&gt;Wrote 12783792 bytes (3185393 compressed) at 0x00000000 in 47.7 seconds
Verifying written data...
Hash of data verified.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 50 seconds for just under 12.8 MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Confirm the version
&lt;/h3&gt;

&lt;p&gt;Look for &lt;code&gt;app_init: App version: 1.4.4&lt;/code&gt; in the boot log. With that in place, pairing succeeded on the first try, and I went straight through AI agent setup, Wi-Fi setup, and an actual voice conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other traps worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Opening the serial port reboots the device
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;rst:0x15 (USB_UART_CHIP_RESET),boot:0x2b (SPI_FAST_FLASH_BOOT)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You cannot capture logs and operate the device at the same time.&lt;/strong&gt; Opening the port in the middle of the setup wizard sends you back to the start (it did for me). The flip side is useful: if you want the boot log from the very first line, open the port and then trigger a reset.&lt;/p&gt;

&lt;h3&gt;
  
  
  There is no interactive serial console
&lt;/h3&gt;

&lt;p&gt;Sending commands produces no response, and the write itself blocks. Treat the serial port as read-only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not power both USB-C ports at once
&lt;/h3&gt;

&lt;p&gt;The unit has a USB-C port on the CoreS3 and another on the heel (base). 5V fed into the CoreS3 side is routed to the base through the M-BUS &lt;code&gt;BUS_5V&lt;/code&gt; line, so &lt;strong&gt;powering both ports puts two supplies on the same rail&lt;/strong&gt;. The official documentation's "try powering from both ports" means one at a time. The heel port is the recommended one for power and flashing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Servo zero positions survive a full flash erase
&lt;/h3&gt;

&lt;p&gt;I backed up NVS before flashing, but the same zero positions were read back afterwards (&lt;code&gt;[ScsServo] id: 1 get zero pos: 460 from settings&lt;/code&gt;). &lt;strong&gt;Those values appear to live in the servos themselves&lt;/strong&gt;, so a full-flash rewrite does not lose them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A separate warning about charging
&lt;/h2&gt;

&lt;p&gt;The firmware sets the AXP2101 charge current to &lt;strong&gt;700mA&lt;/strong&gt; at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;setChargerConstantCurr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XPOWERS_AXP2101_CHG_CUR_700MA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ESP32-S3 enumerates as a USB 2.0 device, so &lt;strong&gt;a PC USB port gives you 500mA at most&lt;/strong&gt;. Combined with the system draw (LCD, camera, servos, radio) that exceeds the input limit, and &lt;strong&gt;charging never starts from a PC port&lt;/strong&gt;. Use a USB charger with headroom at 5V.&lt;/p&gt;

&lt;p&gt;If the servos do not report their current position (&lt;code&gt;[ScsServo] ignore invalid current pos: -1&lt;/code&gt;), read that as another sign of insufficient power.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;No devices found&lt;/code&gt; &lt;strong&gt;did not describe what was happening&lt;/strong&gt;. The BLE connection and the handshake both succeeded; the failure was in response verification&lt;/li&gt;
&lt;li&gt;The real cause was &lt;strong&gt;old factory firmware&lt;/strong&gt;, fixed by flashing the latest official build over USB&lt;/li&gt;
&lt;li&gt;OTA ↔ Wi-Fi ↔ pairing form a loop, so &lt;strong&gt;USB flashing is the only exit&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not over-trust the error string — capture the log.&lt;/strong&gt; Every minute I spent re-checking permissions would have been saved by one serial capture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scripts and the full write-up are in the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/yasumorishima/stackchan-lab" rel="noopener noreferrer"&gt;https://github.com/yasumorishima/stackchan-lab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a href="https://docs.m5stack.com/en/stackchan" rel="noopener noreferrer"&gt;https://docs.m5stack.com/en/stackchan&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Firmware / app / server source: &lt;a href="https://github.com/m5stack/StackChan" rel="noopener noreferrer"&gt;https://github.com/m5stack/StackChan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>m5stack</category>
      <category>esp32</category>
      <category>iot</category>
      <category>ble</category>
    </item>
    <item>
      <title>My Raspberry Pi 5 Kept Vanishing From the Network — Diagnosing With journalctl and Fixing It With a Watchdog</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Tue, 23 Jun 2026 14:33:34 +0000</pubDate>
      <link>https://dev.to/yasumorishima/my-raspberry-pi-5-kept-vanishing-from-the-network-diagnosing-with-journalctl-and-fixing-it-with-a-1m5p</link>
      <guid>https://dev.to/yasumorishima/my-raspberry-pi-5-kept-vanishing-from-the-network-diagnosing-with-journalctl-and-fixing-it-with-a-1m5p</guid>
      <description>&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I run a headless Raspberry Pi 5 (over Wi-Fi) via SSH and Tailscale. Every so often it would &lt;strong&gt;stay powered on (LED lit) but completely vanish from the network&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH over LAN → timeout&lt;/li&gt;
&lt;li&gt;SSH over Tailscale → timeout&lt;/li&gt;
&lt;li&gt;RDP → timeout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it kept recurring — every time I "fixed" it by power-cycling. Here is how I actually diagnosed it and set up auto-recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: &lt;code&gt;tailscale status&lt;/code&gt; tells you device-side vs your-side
&lt;/h2&gt;

&lt;p&gt;When SSH hangs, it's tempting to assume "the Pi died." But the problem can be &lt;strong&gt;on your side&lt;/strong&gt; (your PC's Wi-Fi / VPN). Rule that out first.&lt;/p&gt;

&lt;p&gt;If you use Tailscale, &lt;code&gt;tailscale status&lt;/code&gt; is the fastest check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100.x.y.z   raspberrypi   user@   linux   active; relay "..."; offline, last seen 1h ago, tx 124956 rx 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is the peer line: &lt;strong&gt;&lt;code&gt;offline, last seen 1h ago&lt;/code&gt;&lt;/strong&gt;. This comes from Tailscale's coordination server — it's a &lt;strong&gt;third-party fact, independent of your own SSH attempt&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;peer &lt;code&gt;offline&lt;/code&gt; → the Pi really did drop off the network&lt;/li&gt;
&lt;li&gt;peer &lt;code&gt;active&lt;/code&gt;/online → it's &lt;em&gt;your&lt;/em&gt; side that's broken (your VPN/network)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;last seen&lt;/code&gt; also tells you &lt;strong&gt;when&lt;/strong&gt; it dropped. &lt;code&gt;tx ... rx 0&lt;/code&gt; means "you're sending, but getting zero bytes back."&lt;/p&gt;

&lt;p&gt;Confirm your own side is healthy too via &lt;code&gt;tailscale status --json&lt;/code&gt; → &lt;code&gt;BackendState=Running&lt;/code&gt; / &lt;code&gt;Self.Online=true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: After recovery, read &lt;code&gt;journalctl -b -1&lt;/code&gt; (the &lt;em&gt;previous&lt;/em&gt; boot)
&lt;/h2&gt;

&lt;p&gt;This is the part people miss. While the Pi is off the network you obviously can't pull its logs — you read them after it's back. But &lt;strong&gt;the moment you reboot, the "current boot" is brand new&lt;/strong&gt;, so &lt;code&gt;journalctl&lt;/code&gt; (default) and &lt;code&gt;dmesg&lt;/code&gt; won't contain the moment it dropped.&lt;/p&gt;

&lt;p&gt;The drop is in the &lt;strong&gt;previous boot&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# tail of the previous boot&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;

&lt;span class="c"&gt;# only wlan0 / network-related lines&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"wlan0|cfg80211|brcmfmac|LinkChange|network is unreachable"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I found, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;19:57  avahi-daemon: Withdrawing address record ... on wlan0   # IPv6 address flapping
20:08  tailscaled: LinkChange: major, rebinding ... rebind-reason=[time-jumped(13m50s),ips-changed,protocols-changed]
22:06  tailscaled: ... connect: network is unreachable          # fully down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part: &lt;strong&gt;the logs kept flowing the whole time&lt;/strong&gt;. So this was not a full OS freeze or OOM — only the &lt;strong&gt;wlan0 / network layer dropped&lt;/strong&gt; while the OS stayed alive (no &lt;code&gt;oom&lt;/code&gt;, no &lt;code&gt;panic&lt;/code&gt; at the tail of &lt;code&gt;journalctl -b -1&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I couldn't pin the &lt;em&gt;root trigger&lt;/em&gt; (why wlan0 dropped) from the logs — there was no explicit driver crash line, and &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; read &lt;code&gt;0x0&lt;/code&gt; after recovery (no undervoltage flag). So from here it's a &lt;strong&gt;symptomatic fix for wlan0 drops in general&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: a NetworkManager watchdog
&lt;/h2&gt;

&lt;p&gt;If "the OS is alive but only the network is down," then a cron job that watches for it and restarts networking can auto-recover. Recent Pi OS uses NetworkManager (&lt;code&gt;resolv.conf&lt;/code&gt; says &lt;code&gt;# Generated by NetworkManager&lt;/code&gt;), so restart that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/home/pi/.local/bin/net-watchdog.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# If the gateway is unreachable, restart NetworkManager to bring wlan0 back.&lt;/span&gt;
&lt;span class="nv"&gt;GATEWAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.1.1   &lt;span class="c"&gt;# replace with your router IP&lt;/span&gt;
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/pi/logs/net-watchdog.log
&lt;span class="k"&gt;if &lt;/span&gt;ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 &lt;span class="nt"&gt;-W&lt;/span&gt; 3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GATEWAY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Is&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; gateway &lt;/span&gt;&lt;span class="nv"&gt;$GATEWAY&lt;/span&gt;&lt;span class="s2"&gt; unreachable, restarting NetworkManager"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
systemctl restart NetworkManager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/cron.d/net-watchdog&lt;/code&gt; (every 3 minutes, as root):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/3 * * * * root /home/pi/.local/bin/net-watchdog.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /home/pi/.local/bin/net-watchdog.sh
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/pi/logs
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"*/3 * * * * root /home/pi/.local/bin/net-watchdog.sh"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cron.d/net-watchdog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify it does NOT restart when the network is fine
&lt;/h3&gt;

&lt;p&gt;You don't want spurious restarts. Run it once by hand while the network is healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/home/pi/.local/bin/net-watchdog.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;        &lt;span class="c"&gt;# → 0&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /home/pi/logs/net-watchdog.log   &lt;span class="c"&gt;# → no such file (= it did not restart anything)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;code&gt;ping&lt;/code&gt; succeeds, nothing is logged and &lt;code&gt;systemctl restart&lt;/code&gt; never runs. It only acts when the network is actually down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Powered on but gone from the network" is usually a &lt;strong&gt;network-layer drop&lt;/strong&gt; — the OS is still alive.&lt;/li&gt;
&lt;li&gt;Fastest triage: &lt;strong&gt;&lt;code&gt;tailscale status&lt;/code&gt; (third-party offline record) → after recovery, &lt;code&gt;journalctl -b -1&lt;/code&gt; (the previous boot)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;gateway-ping → restart-NetworkManager watchdog&lt;/strong&gt; in cron auto-recovers within minutes, as long as the OS keeps running.&lt;/li&gt;
&lt;li&gt;Caveat: a cron-driven watchdog can't help if the OS itself stops executing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I still haven't pinned the root cause, but at least I'm out of the "power-cycle it by hand every time" loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow-up: a &lt;em&gt;selective&lt;/em&gt; outage the watchdog couldn't see — and watchdog v2
&lt;/h2&gt;

&lt;p&gt;About two weeks after deploying the watchdog above, the Pi died in a &lt;strong&gt;completely different way&lt;/strong&gt; — and since the gateway ping still succeeded, the watchdog never fired.&lt;/p&gt;

&lt;h3&gt;
  
  
  The symptom (nothing like last time)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;SSH (over Tailscale) worked fine&lt;/li&gt;
&lt;li&gt;Ping to the gateway worked fine&lt;/li&gt;
&lt;li&gt;But DNS resolution was completely dead (&lt;code&gt;getent hosts github.com&lt;/code&gt; failed)&lt;/li&gt;
&lt;li&gt;Behavior differed &lt;em&gt;per destination&lt;/em&gt;:

&lt;ul&gt;
&lt;li&gt;Google properties returned HTTPS 200&lt;/li&gt;
&lt;li&gt;GitHub / Cloudflare (1.1.1.1) / everything else → TCP connect timeout&lt;/li&gt;
&lt;li&gt;IPv6 was entirely dead&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With "only Google works," it &lt;em&gt;looks&lt;/em&gt; like an ISP or router outage. Spoiler: it was &lt;strong&gt;the Pi itself again — a wedged wlan0 Wi-Fi client state&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Triage 1: hit the same destinations from another device on the same router (the decisive check)
&lt;/h3&gt;

&lt;p&gt;From a PC on the same Wi-Fi, GitHub and everything else worked normally. → &lt;strong&gt;The router and the ISP are innocent; the problem is Pi-specific.&lt;/strong&gt; Skipping this check would have sent me down the useless "reboot the router" path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Triage 2: probe each DNS server directly over UDP
&lt;/h3&gt;

&lt;p&gt;No &lt;code&gt;dig&lt;/code&gt; or &lt;code&gt;nslookup&lt;/code&gt; on the Pi? A few lines of python3 can send a raw DNS query:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# minimal DNS A query for github.com
&lt;/span&gt;    &lt;span class="n"&gt;pkt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHHHH&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x0100&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="mi"&gt;0&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;github com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;pkt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;
    &lt;span class="n"&gt;pkt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HH&lt;/span&gt;&lt;span class="sh"&gt;'&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_DGRAM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;settimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recvfrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OK answers=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FAIL &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;type&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;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;192.168.1.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;100.100.100.100&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.8.8.8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.1.1.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;code&gt;router=FAIL / Tailscale MagicDNS=FAIL / 8.8.8.8=OK / 1.1.1.1=FAIL&lt;/code&gt;. So DNS wasn't "down" — &lt;strong&gt;traffic to specific destinations was down&lt;/strong&gt;, and DNS failure was just one symptom. &lt;code&gt;ip route get&lt;/code&gt; and the firewall were both normal.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: a &lt;em&gt;full&lt;/em&gt; wlan0 reconnect (&lt;code&gt;reapply&lt;/code&gt; is not enough)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;nmcli dev reapply wlan0&lt;/code&gt; (re-applying the config) did &lt;strong&gt;not&lt;/strong&gt; fix it. What fixed it was a full &lt;strong&gt;disconnect → connect&lt;/strong&gt;. Since SSH itself rides on wlan0, detach the command so it survives your own disconnection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo nohup &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'nmcli dev disconnect wlan0; sleep 3; nmcli dev connect wlan0'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 20 seconds later everything was back: all destinations 200, and the router DNS that had "looked dead" was answering again. The DNS death, the selective timeouts, and the IPv6 blackout were all symptoms of one root cause: the wedged wlan0 client state.&lt;/p&gt;

&lt;h3&gt;
  
  
  watchdog v2: also probe external connectivity
&lt;/h3&gt;

&lt;p&gt;Gateway ping alone misses this failure mode, so I added a second tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# net-watchdog v2: two-tier self-healing&lt;/span&gt;
&lt;span class="c"&gt;# tier1: gateway unreachable -&amp;gt; restart NetworkManager&lt;/span&gt;
&lt;span class="c"&gt;# tier2: gateway OK but external connectivity mostly dead (&amp;lt;2 of 4 probes) -&amp;gt; full wlan0 reconnect&lt;/span&gt;
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/pi/logs/net-watchdog.log
&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/pi/logs/net-watchdog.last-reconnect
&lt;span class="nv"&gt;GW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.1.1   &lt;span class="c"&gt;# replace with your router IP&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/pi/logs

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; ping &lt;span class="nt"&gt;-c3&lt;/span&gt; &lt;span class="nt"&gt;-W3&lt;/span&gt; &lt;span class="nv"&gt;$GW&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Is&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; gateway unreachable -&amp;gt; restart NetworkManager"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$LOG&lt;/span&gt;
  systemctl restart NetworkManager
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;alive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;url &lt;span class="k"&gt;in &lt;/span&gt;https://www.google.com https://github.com https://1.1.1.1 https://www.yahoo.co.jp&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-4&lt;/span&gt; &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;alive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;alive+1&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$alive&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;now &lt;span class="o"&gt;-&lt;/span&gt; last&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 1800 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Is&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; external dead (alive=&lt;/span&gt;&lt;span class="nv"&gt;$alive&lt;/span&gt;&lt;span class="s2"&gt;/4) but reconnected &amp;lt;30min ago, skip"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$LOG&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Is&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; gateway OK but external dead (alive=&lt;/span&gt;&lt;span class="nv"&gt;$alive&lt;/span&gt;&lt;span class="s2"&gt;/4) -&amp;gt; wlan0 reconnect"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$LOG&lt;/span&gt;
  nmcli dev disconnect wlan0
  &lt;span class="nb"&gt;sleep &lt;/span&gt;3
  nmcli dev connect wlan0
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Design notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2 of 4 independent probes alive = healthy&lt;/strong&gt; — a single site being down never triggers a reconnect&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://1.1.1.1&lt;/code&gt; is IP-literal, so this probe &lt;strong&gt;still works when DNS is dead&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Reconnects have a &lt;strong&gt;30-minute cooldown&lt;/strong&gt; (no flapping)&lt;/li&gt;
&lt;li&gt;As with v1: run it by hand while healthy and confirm it does nothing before wiring it into cron&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Follow-up takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A gateway-ping watchdog is blind to "selective outage caused by a wedged Wi-Fi client state"&lt;/li&gt;
&lt;li&gt;"Only some sites work" &lt;em&gt;looks&lt;/em&gt; like an ISP problem but &lt;strong&gt;can be your own device&lt;/strong&gt;. The fastest triage is hitting the same destinations from another device on the same LAN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nmcli dev reapply&lt;/code&gt; and a full disconnect/connect are different things — escalate to the full reconnect when reapply doesn't cut it&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>networking</category>
      <category>homelab</category>
    </item>
    <item>
      <title>I Had Claude Fable 5 Review My Indie Diary App Before It Got Pulled — The Bugs It Caught</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:00:53 +0000</pubDate>
      <link>https://dev.to/yasumorishima/i-had-claude-fable-5-review-my-indie-diary-app-before-it-got-pulled-the-bugs-it-caught-8ca</link>
      <guid>https://dev.to/yasumorishima/i-had-claude-fable-5-review-my-indie-diary-app-before-it-got-pulled-the-bugs-it-caught-8ca</guid>
      <description>&lt;h2&gt;
  
  
  Intro
&lt;/h2&gt;

&lt;p&gt;I had Claude Fable 5 review and fix my Flutter diary app, &lt;strong&gt;Daily Diary&lt;/strong&gt; (live on Google Play), before a release. This is a record of that.&lt;/p&gt;

&lt;p&gt;Fable 5 was pulled worldwide just three days after launch (June 9 -&amp;gt; June 12, 2026) by a US Commerce Department export-control directive — but I got this pass in right before it went dark. Instead of generic "it caught what other models missed" claims, I'm pasting the &lt;strong&gt;actual diffs&lt;/strong&gt; from my repo's commit history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app: Daily Diary
&lt;/h2&gt;

&lt;p&gt;A simple offline diary app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 &lt;strong&gt;Offline-first&lt;/strong&gt;: data stays on the device, no cloud&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;5 languages&lt;/strong&gt;: Japanese / English / Chinese / Korean / Spanish&lt;/li&gt;
&lt;li&gt;🌙 Dark mode, 📅 calendar with mood indicators, 📊 stats (streaks, mood trends), 🔍 full-text search, 🎲 random past entry, 💾 JSON export/import&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.diary.daily" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.diary.daily&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Flutter 3.x / Dart, Provider, Hive (local NoSQL), Flutter intl.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I asked for
&lt;/h2&gt;

&lt;p&gt;Not "rewrite everything" — more like &lt;em&gt;"find and fix what's questionable quality-wise"&lt;/em&gt;: resolve &lt;code&gt;flutter analyze&lt;/code&gt; warnings (there were 47), set up tests, add CI, update dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. BuildContext used across an async gap
&lt;/h2&gt;

&lt;p&gt;Several spots touched &lt;code&gt;BuildContext&lt;/code&gt; (&lt;code&gt;Navigator&lt;/code&gt;, localization) after an &lt;code&gt;await&lt;/code&gt;. If the widget is disposed during the await, that throws. Fable inserted &lt;code&gt;if (!mounted) return;&lt;/code&gt; right after the await:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   final db = DatabaseService();
   final existingEntry = await db.getEntryByDate(_selectedDate);
&lt;span class="gi"&gt;+  if (!mounted) return;
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;   final result = await Navigator.push(
     context,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You rarely hit this in normal use, so it's easy to miss even when testing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Hoisting the l10n lookup before the await
&lt;/h2&gt;

&lt;p&gt;In the import handler &lt;code&gt;_importData&lt;/code&gt;, &lt;code&gt;AppLocalizations.of(context)&lt;/code&gt; (localized strings) was looked up repeatedly &lt;em&gt;after&lt;/em&gt; awaits. It got hoisted to a single lookup before the first await, with a &lt;code&gt;mounted&lt;/code&gt; guard before showing the confirmation dialog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;       _isImporting = true;
     });
&lt;span class="gi"&gt;+
+    final l10n = AppLocalizations.of(context)!;
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;     try {
       final result = await FilePicker...
       ...
       if (file.bytes == null) {
&lt;span class="gd"&gt;-        final l10n = AppLocalizations.of(context)!;   // looked up after await
&lt;/span&gt;         throw Exception(l10n.fileReadError);
       }
       ...
&lt;span class="gd"&gt;-      final l10n = AppLocalizations.of(context)!;
&lt;/span&gt;&lt;span class="gi"&gt;+      if (!mounted) return;                            // guard before dialog
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The test that tested nothing
&lt;/h2&gt;

&lt;p&gt;The leftover template &lt;code&gt;widget_test.dart&lt;/code&gt; referenced a non-existent &lt;code&gt;MyApp&lt;/code&gt; and didn't even compile (= effectively zero tests). It was removed and replaced with &lt;strong&gt;12 real tests&lt;/strong&gt; for the model and Hive persistence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;removed test/widget_test.dart             (broken template)
added  test/diary_entry_test.dart   +49   (model serialization, etc.)
added  test/database_service_test.dart +92 (Hive-backed save/load)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"You think you have tests, you don't" is the scariest state to be in, so fixing this mattered most.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. 47 analyzer warnings to zero
&lt;/h2&gt;

&lt;p&gt;The headline one was the &lt;code&gt;Color.withOpacity()&lt;/code&gt; deprecation (&lt;code&gt;withValues&lt;/code&gt; is now preferred for color precision). 27 spots replaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  color: Colors.white.withOpacity(0.1),
&lt;/span&gt;&lt;span class="gi"&gt;+  color: Colors.white.withValues(alpha: 0.1),
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus removing the deprecated &lt;code&gt;ColorScheme.background / onBackground&lt;/code&gt;, and migrating &lt;code&gt;RadioListTile&lt;/code&gt; (&lt;code&gt;groupValue / onChanged&lt;/code&gt; removed) to the &lt;code&gt;RadioGroup&lt;/code&gt; ancestor API. &lt;code&gt;dart fix --apply&lt;/code&gt; handled the mechanical ones; API migrations were done by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Major dependency bumps
&lt;/h2&gt;

&lt;p&gt;Bumped the main dependencies, with two breaking API changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  google_mobile_ads: ^4.0.0
&lt;/span&gt;&lt;span class="gi"&gt;+  google_mobile_ads: ^9.0.0
&lt;/span&gt;&lt;span class="gd"&gt;-  share_plus: ^7.2.1
-  file_picker: ^8.1.4
&lt;/span&gt;&lt;span class="gi"&gt;+  share_plus: ^12.0.2
+  file_picker: ^11.0.2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;share_plus&lt;/strong&gt;: &lt;code&gt;Share.shareXFiles(...)&lt;/code&gt; -&amp;gt; &lt;code&gt;SharePlus.instance.share(ShareParams(...))&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  await Share.shareXFiles(
-    [XFile(file.path)],
-    subject: fileName,
&lt;/span&gt;&lt;span class="gi"&gt;+  await SharePlus.instance.share(
+    ShareParams(
+      files: [XFile(file.path)],
+      subject: fileName,
+    ),
&lt;/span&gt;   );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;file_picker&lt;/strong&gt;: the instance API &lt;code&gt;FilePicker.platform.pickFiles(...)&lt;/code&gt; became a static &lt;code&gt;FilePicker.pickFiles(...)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  final result = await FilePicker.platform.pickFiles(
&lt;/span&gt;&lt;span class="gi"&gt;+  final result = await FilePicker.pickFiles(
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the pins: share_plus 13 conflicts with file_picker 11, and file_picker 12 is beta-only — so I settled on &lt;strong&gt;share_plus 12 / file_picker 11&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Added CI
&lt;/h2&gt;

&lt;p&gt;A new &lt;code&gt;flutter-ci.yml&lt;/code&gt; runs &lt;code&gt;flutter analyze&lt;/code&gt; + tests on push/PR, so the project can't slip back into the "broken test" state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it felt as a solo dev
&lt;/h2&gt;

&lt;p&gt;Honestly, what Fable caught wasn't flashy features — it was the class of things I systematically don't check on my own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;async-gap mounted -&amp;gt; only crashes on certain timing&lt;/li&gt;
&lt;li&gt;a broken test -&amp;gt; you think you have tests, you don't&lt;/li&gt;
&lt;li&gt;a pile of deprecations -&amp;gt; works now, breaks later&lt;/li&gt;
&lt;li&gt;stale dependencies -&amp;gt; major bumps get postponed out of fear of breaking changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I can write the features myself, but "only fails under specific conditions," "are the tests actually testing anything," and "deprecated-API + dependency housekeeping" tend to slip without a dedicated reviewer. One thorough pass on that was genuinely useful for a side project.&lt;/p&gt;

&lt;p&gt;There are limits too: device-UI-dependent behavior (ads, export/import sharing) can't be verified in CI — I still test that on a real device. Concept, design, device testing, store submission, and final review stay my job.&lt;/p&gt;

&lt;p&gt;It's a shame it went dark after three days, but I'm glad I got this pass in before it did.&lt;/p&gt;

&lt;p&gt;Daily Diary is live on Google Play — give it a try:&lt;/p&gt;

&lt;p&gt;📱 &lt;a href="https://play.google.com/store/apps/details?id=com.diary.daily" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.diary.daily&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>claude</category>
      <category>ai</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>transform: translateY(0) Breaks position: fixed — A Hidden Trap in SPA Animations</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Mon, 06 Apr 2026 09:55:09 +0000</pubDate>
      <link>https://dev.to/yasumorishima/transform-translatey0-breaks-position-fixed-a-hidden-trap-in-spa-animations-2hed</link>
      <guid>https://dev.to/yasumorishima/transform-translatey0-breaks-position-fixed-a-hidden-trap-in-spa-animations-2hed</guid>
      <description>&lt;h2&gt;
  
  
  The Bug
&lt;/h2&gt;

&lt;p&gt;One day I got this bug report on my Next.js site:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Clicking a photo near the bottom of the gallery opens a lightbox, but it's completely black. Scroll up and the image is there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;code&gt;position: fixed; inset: 0&lt;/code&gt; overlay was not covering the viewport — it was stuck at the top of the page. Browser bug? No. This is CSS working exactly as specified.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Reproduce
&lt;/h2&gt;

&lt;p&gt;Two ingredients:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An ancestor element with &lt;code&gt;transform&lt;/code&gt; set&lt;/strong&gt; (even &lt;code&gt;translateY(0)&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A descendant with &lt;code&gt;position: fixed&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Page transition animation */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;page-enter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c"&gt;/* The culprit */&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.page-enter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page-enter&lt;/span&gt; &lt;span class="m"&gt;0.35s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* both = keeps final values */&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 tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lightbox (descendant of .page-enter)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-0 z-50 bg-black/90"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Near the top of the page, everything looks fine. Scroll down and open the lightbox — it renders at the &lt;strong&gt;top of the ancestor element&lt;/strong&gt;, not the viewport.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happens — The CSS Spec
&lt;/h2&gt;

&lt;p&gt;From &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed" rel="noopener noreferrer"&gt;MDN's &lt;code&gt;position: fixed&lt;/code&gt; documentation&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The element is positioned relative to the initial containing block established by the viewport, &lt;strong&gt;except when one of its ancestors has a &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;perspective&lt;/code&gt;, or &lt;code&gt;filter&lt;/code&gt; property set to something other than &lt;code&gt;none&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ancestor's &lt;code&gt;transform&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;fixed&lt;/code&gt; is relative to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;none&lt;/code&gt; or unset&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;viewport&lt;/strong&gt; (expected)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;translateY(0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;that ancestor&lt;/strong&gt; (broken)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;translateY(12px)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;that ancestor&lt;/strong&gt; (broken)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;translateY(0)&lt;/code&gt; is not the same as no transform.&lt;/strong&gt; It's a transform that moves nothing — but the CSS engine still creates a new containing block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;animation-fill-mode: both&lt;/code&gt; Trap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.page-enter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;page-enter&lt;/span&gt; &lt;span class="m"&gt;0.35s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="nb"&gt;both&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;p&gt;&lt;code&gt;both&lt;/code&gt; (&lt;code&gt;forwards&lt;/code&gt; + &lt;code&gt;backwards&lt;/code&gt;) keeps the final keyframe values &lt;strong&gt;after the animation ends&lt;/strong&gt;. So &lt;code&gt;transform: translateY(0)&lt;/code&gt; persists for the lifetime of the element.&lt;/p&gt;

&lt;p&gt;The same applies to JavaScript inline styles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// IntersectionObserver fadeIn component&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translateY(0)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translateY(16px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// After visible=true, translateY(0) stays forever&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Blast Radius
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Every &lt;code&gt;fixed&lt;/code&gt; descendant&lt;/strong&gt; of a &lt;code&gt;transform&lt;/code&gt;-bearing ancestor is affected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lightboxes / modals&lt;/li&gt;
&lt;li&gt;Toast notifications&lt;/li&gt;
&lt;li&gt;Cookie consent banners&lt;/li&gt;
&lt;li&gt;PWA install prompts&lt;/li&gt;
&lt;li&gt;Progress bars&lt;/li&gt;
&lt;li&gt;Scroll-to-top buttons&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bottom navs and sticky headers may not visibly break (they sit at viewport edges), but they are technically affected too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Use &lt;code&gt;transform: none&lt;/code&gt; (Most Important)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;page-enter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* Not translateY(0) */&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 tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translateY(16px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;transform: none&lt;/code&gt; means "no transform is applied" — no containing block is created.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use &lt;code&gt;createPortal&lt;/code&gt; to Escape the DOM Tree (Defensive)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createPortal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Lightbox&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="nf"&gt;createPortal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-0 z-50 bg-black/90"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="c1"&gt;// Renders at body root — immune to ancestor CSS&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;p&gt;No matter what ancestors do, the overlay is not affected. This is a best practice for any viewport-covering overlay.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Do Both (Recommended)
&lt;/h3&gt;

&lt;p&gt;Fix the root cause with &lt;code&gt;transform: none&lt;/code&gt;, and add &lt;code&gt;createPortal&lt;/code&gt; as defense-in-depth. If someone later adds a new &lt;code&gt;transform&lt;/code&gt; ancestor, overlays still work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Don't&lt;/th&gt;
&lt;th&gt;Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Use &lt;code&gt;translateY(0)&lt;/code&gt; as animation end value&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;transform: none&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render &lt;code&gt;fixed&lt;/code&gt; overlays deep in the DOM tree&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;createPortal(document.body)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add animations without checking &lt;code&gt;fixed&lt;/code&gt; elements&lt;/td&gt;
&lt;td&gt;Audit &lt;code&gt;fixed&lt;/code&gt; descendants when adding &lt;code&gt;transform&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;translateY(0)&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt; look identical but behave differently.&lt;/strong&gt; Miss this spec detail and every overlay on your site breaks the moment you add a page transition animation.&lt;/p&gt;

</description>
      <category>css</category>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>NPB 2021 Backtest: Could a Bayesian Model Predict Last-Place-to-Champion?</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Tue, 24 Mar 2026 02:51:07 +0000</pubDate>
      <link>https://dev.to/yasumorishima/npb-2021-backtest-could-a-bayesian-model-predict-last-place-to-champion-3kl2</link>
      <guid>https://dev.to/yasumorishima/npb-2021-backtest-could-a-bayesian-model-predict-last-place-to-champion-3kl2</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In a &lt;a href="https://dev.to/yasumorishima/adding-bayesian-ensemble-monte-carlo-to-an-npb-prediction-app-58po"&gt;previous article&lt;/a&gt;, I added Bayesian integration to my NPB prediction system. The 8-year backtest showed "97% probability of beating Marcel." But how did it perform in the &lt;strong&gt;worst year for predictions&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;2021 was NPB's biggest upset: both &lt;strong&gt;Yakult (CL)&lt;/strong&gt; and &lt;strong&gt;Orix (PL)&lt;/strong&gt; went from last place to champions. I ran a full backtest with &lt;strong&gt;25 new foreign players individually projected&lt;/strong&gt; using FanGraphs and Baseball Savant data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/yasumorishima/npb-2021-backtest" rel="noopener noreferrer"&gt;npb-2021-backtest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Main model&lt;/strong&gt;: &lt;a href="https://github.com/yasumorishima/npb-prediction" rel="noopener noreferrer"&gt;npb-prediction&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Team Standings: Predicted vs Actual
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Central League
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;th&gt;Bayes (no foreign)&lt;/th&gt;
&lt;th&gt;Bayes (with foreign)&lt;/th&gt;
&lt;th&gt;Foreign Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yakult&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73W (1st)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;69.5W (4th)&lt;/td&gt;
&lt;td&gt;70.7W (4th)&lt;/td&gt;
&lt;td&gt;+1.2W (Santana, Osuna)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hanshin&lt;/td&gt;
&lt;td&gt;77W (2nd)&lt;/td&gt;
&lt;td&gt;72.8W (2nd)&lt;/td&gt;
&lt;td&gt;72.6W (2nd)&lt;/td&gt;
&lt;td&gt;-0.2W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Giants&lt;/td&gt;
&lt;td&gt;61W (3rd)&lt;/td&gt;
&lt;td&gt;83.1W (1st)&lt;/td&gt;
&lt;td&gt;84.3W (1st)&lt;/td&gt;
&lt;td&gt;+1.2W (Smoak, Thames)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Pacific League
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;th&gt;Bayes (no foreign)&lt;/th&gt;
&lt;th&gt;Bayes (with foreign)&lt;/th&gt;
&lt;th&gt;Foreign Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70W (1st)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;64.5W (6th)&lt;/td&gt;
&lt;td&gt;62.0W (6th)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-2.5W (worse)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SoftBank&lt;/td&gt;
&lt;td&gt;60W (4th)&lt;/td&gt;
&lt;td&gt;77.6W (1st)&lt;/td&gt;
&lt;td&gt;76.2W (1st)&lt;/td&gt;
&lt;td&gt;-1.4W&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;MAE: 10.4 wins → 10.7 wins.&lt;/strong&gt; Foreign player predictions slightly worsened accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Foreign Player Predictions vs Actual
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Accurate Predictions (average MLB players)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Pred OPS&lt;/th&gt;
&lt;th&gt;Actual OPS&lt;/th&gt;
&lt;th&gt;Diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kevin Cron&lt;/td&gt;
&lt;td&gt;Carp&lt;/td&gt;
&lt;td&gt;.703&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;.701&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-.002&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jose Osuna&lt;/td&gt;
&lt;td&gt;Swallows&lt;/td&gt;
&lt;td&gt;.683&lt;/td&gt;
&lt;td&gt;.694&lt;/td&gt;
&lt;td&gt;+.011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cy Sneed&lt;/td&gt;
&lt;td&gt;Swallows&lt;/td&gt;
&lt;td&gt;ERA 3.53&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ERA 3.41&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Major Misses (extreme players)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Pred OPS&lt;/th&gt;
&lt;th&gt;Actual OPS&lt;/th&gt;
&lt;th&gt;Diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mike Gerber&lt;/td&gt;
&lt;td&gt;Dragons&lt;/td&gt;
&lt;td&gt;.862&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;.352&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-.510&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mel Rojas Jr.&lt;/td&gt;
&lt;td&gt;Tigers&lt;/td&gt;
&lt;td&gt;.867&lt;/td&gt;
&lt;td&gt;.663&lt;/td&gt;
&lt;td&gt;-.204&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domingo Santana&lt;/td&gt;
&lt;td&gt;Swallows&lt;/td&gt;
&lt;td&gt;.713&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;.877&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+.164&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Gerber&lt;/strong&gt; had an MLB wOBA of .127 (49.3% K rate) — the model over-regressed toward the mean, predicting .862 OPS when the actual was .352.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Santana&lt;/strong&gt; was predicted from 84 PA in 2020 (COVID-shortened). His career .757 OPS would have been more predictive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Drove the 2021 Standings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Yakult's Championship Run
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;2020&lt;/th&gt;
&lt;th&gt;2021&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tetsuto Yamada&lt;/td&gt;
&lt;td&gt;OPS .766&lt;/td&gt;
&lt;td&gt;OPS .885&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+.119&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domingo Santana&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;OPS .877&lt;/td&gt;
&lt;td&gt;New signing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noboru Shimizu&lt;/td&gt;
&lt;td&gt;ERA 3.54&lt;/td&gt;
&lt;td&gt;ERA 2.39&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-1.15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Orix's Championship Run
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;2020&lt;/th&gt;
&lt;th&gt;2021&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Yutaro Sugimoto&lt;/td&gt;
&lt;td&gt;OPS .695&lt;/td&gt;
&lt;td&gt;OPS .931&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;+.236&lt;/strong&gt; (HR King at 31)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hiroya Miyagi&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;ERA 2.51 (147IP)&lt;/td&gt;
&lt;td&gt;20-year-old, 13 wins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yoshinobu Yamamoto&lt;/td&gt;
&lt;td&gt;ERA 2.20&lt;/td&gt;
&lt;td&gt;ERA 1.39&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;-0.81&lt;/strong&gt; (Sawamura Award)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Sugimoto and Miyagi's breakouts were impossible to predict from past data.&lt;/strong&gt; This is a structural change, not a statistical fluctuation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Giants Collapse (Predicted 84.3W → Actual 61W)
&lt;/h3&gt;

&lt;p&gt;Sugano (ERA 1.97→3.19), Sakamoto (OPS .844→.657), Maru (OPS .899→.775) — three stars declining simultaneously. The Bayesian model &lt;strong&gt;trusted their skill metrics&lt;/strong&gt; and predicted even higher than Marcel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Findings
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Average MLB players predicted well&lt;/strong&gt; (Cron .703 vs .701 actual)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extreme players over-regressed&lt;/strong&gt; (Gerber .862 vs .352) → need regression limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-year small samples mislead&lt;/strong&gt; (Santana's 84 PA in 2020) → use career stats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad MLB pitchers stay bad in NPB&lt;/strong&gt; (Sparkman 6.02→3.88 pred→6.88 actual)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2021 was driven by Japanese player breakouts&lt;/strong&gt;, not foreign players&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Individual foreign player projections improve accuracy for "average" players but carry risk for extreme cases. In 2021, Japanese player breakouts and collapses determined the standings — foreign player predictions had minimal impact (MAE +0.3 wins).&lt;/p&gt;

&lt;p&gt;This is a personal hobby project. There may be oversights in data collection and verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://baseball-data.com" rel="noopener noreferrer"&gt;Baseball Data Freak&lt;/a&gt; — NPB player stats&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://npb.jp" rel="noopener noreferrer"&gt;NPB Official&lt;/a&gt; — Official records&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.fangraphs.com" rel="noopener noreferrer"&gt;FanGraphs&lt;/a&gt; — MLB wOBA/K%/BB%&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://baseballsavant.mlb.com" rel="noopener noreferrer"&gt;Baseball Savant&lt;/a&gt; — MLB Statcast&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.baseball-reference.com" rel="noopener noreferrer"&gt;Baseball Reference&lt;/a&gt; — MLB/MiLB stats&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>baseball</category>
      <category>python</category>
      <category>bayesian</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Adding Bayesian Ensemble + Monte Carlo to an NPB Prediction App</title>
      <dc:creator>YMori</dc:creator>
      <pubDate>Mon, 23 Mar 2026 21:48:12 +0000</pubDate>
      <link>https://dev.to/yasumorishima/adding-bayesian-ensemble-monte-carlo-to-an-npb-prediction-app-58po</link>
      <guid>https://dev.to/yasumorishima/adding-bayesian-ensemble-monte-carlo-to-an-npb-prediction-app-58po</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I've been running a personal NPB (Japanese pro baseball) prediction app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard&lt;/strong&gt;: &lt;a href="https://npb-prediction.streamlit.app/" rel="noopener noreferrer"&gt;npb-prediction.streamlit.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/yasumorishima/npb-prediction" rel="noopener noreferrer"&gt;npb-prediction&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It used Marcel projections (3-year weighted average) and ML (XGBoost/LightGBM). Decent, but I wanted better accuracy. After adding Bayesian corrections, the predicted standings changed significantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terms
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Marcel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Predict next year from weighted average of past 3 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bayesian&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Combine prior knowledge with data. Gives uncertainty estimates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Credible interval — range where the true value falls with 80%/95% probability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-base + Slugging. Overall batting metric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ERA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Earned Run Average. Runs allowed per 9 innings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MAE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mean Absolute Error. Average prediction miss. Lower = better&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Problems with the Previous Approach
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem 1: All Foreign Players Treated as "Average"
&lt;/h3&gt;

&lt;p&gt;Marcel needs 3 years of NPB data. First-year foreign players have none, so all 24 of them were treated as league-average. Dalbec (Giants, .355 wOBA in MLB) and Hummel (BayStars, .240 wOBA) were calculated identically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Skill Metrics Ignored
&lt;/h3&gt;

&lt;p&gt;Marcel averages past results directly. Two players with OPS .800 might have very different K% and BB% profiles, which affects how stable their performance will be next year.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: No Uncertainty
&lt;/h3&gt;

&lt;p&gt;"Maki's OPS: .812" gives no sense of how much it might vary. The difference between .750-.870 and .790-.830 matters a lot for team projections.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed with Bayesian Integration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Foreign Players: Average → Individual Predictions
&lt;/h3&gt;

&lt;p&gt;Built a model to convert MLB/KBO stats to NPB projections. For example, a .350 wOBA MLB hitter maps to approximately &lt;code&gt;.350 × 1.235 = .432&lt;/code&gt; NPB-equivalent wOBA.&lt;/p&gt;

&lt;p&gt;All 24 players' names and prior-league stats were individually web-verified (guessing English names from katakana is surprisingly error-prone).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreign hitter examples:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Prior wOBA&lt;/th&gt;
&lt;th&gt;NPB Pred OPS&lt;/th&gt;
&lt;th&gt;80% CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sano&lt;/td&gt;
&lt;td&gt;Dragons&lt;/td&gt;
&lt;td&gt;.370&lt;/td&gt;
&lt;td&gt;.760&lt;/td&gt;
&lt;td&gt;.632–.889&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seymour&lt;/td&gt;
&lt;td&gt;Buffaloes&lt;/td&gt;
&lt;td&gt;.365&lt;/td&gt;
&lt;td&gt;.735&lt;/td&gt;
&lt;td&gt;.607–.863&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dalbec&lt;/td&gt;
&lt;td&gt;Giants&lt;/td&gt;
&lt;td&gt;.355&lt;/td&gt;
&lt;td&gt;.725&lt;/td&gt;
&lt;td&gt;.577–.884&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hummel&lt;/td&gt;
&lt;td&gt;BayStars&lt;/td&gt;
&lt;td&gt;.240&lt;/td&gt;
&lt;td&gt;.694&lt;/td&gt;
&lt;td&gt;.530–.849&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Foreign pitcher examples:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Player&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Prior ERA&lt;/th&gt;
&lt;th&gt;NPB Pred ERA&lt;/th&gt;
&lt;th&gt;80% CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quijada&lt;/td&gt;
&lt;td&gt;Swallows&lt;/td&gt;
&lt;td&gt;3.26&lt;/td&gt;
&lt;td&gt;2.76&lt;/td&gt;
&lt;td&gt;1.28–4.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hjelle&lt;/td&gt;
&lt;td&gt;Buffaloes&lt;/td&gt;
&lt;td&gt;3.90&lt;/td&gt;
&lt;td&gt;3.34&lt;/td&gt;
&lt;td&gt;1.05–5.59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cox&lt;/td&gt;
&lt;td&gt;BayStars&lt;/td&gt;
&lt;td&gt;8.86&lt;/td&gt;
&lt;td&gt;3.36&lt;/td&gt;
&lt;td&gt;1.82–4.85&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Players with poor prior-league stats get pulled toward league average (Bayesian regression effect), but with wider CIs = lower confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Japanese Players: K%/BB%/BABIP Corrections
&lt;/h3&gt;

&lt;p&gt;Three models combined into a final prediction:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Marcel&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;Strong baseline, especially for pitcher ERA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bayesian correction&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;K%/BB%/BABIP/age adjustment on top of Marcel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;XGBoost/LightGBM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Did Accuracy Improve?
&lt;/h2&gt;

&lt;p&gt;8-year backtest (2018–2025, predict each year and compare to actual):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Marcel MAE&lt;/th&gt;
&lt;th&gt;Bayesian MAE&lt;/th&gt;
&lt;th&gt;Improvement prob.&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hitter wOBA&lt;/td&gt;
&lt;td&gt;0.05023&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.04980&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;97.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pitcher ERA&lt;/td&gt;
&lt;td&gt;1.23008&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.22241&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;97.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Small improvement, but consistent — &lt;strong&gt;97% probability of beating Marcel across 8 years&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historical Marcel Accuracy for Context
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Overall (8 years × 12 teams = 96 team-years):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wins MAE&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.4 wins&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg rank error&lt;/td&gt;
&lt;td&gt;1.42 positions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact rank rate&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Within 1 rank&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Recent examples of Marcel misses:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;th&gt;Predicted&lt;/th&gt;
&lt;th&gt;Miss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;Swallows (CL)&lt;/td&gt;
&lt;td&gt;57W (6th)&lt;/td&gt;
&lt;td&gt;72W (4th)&lt;/td&gt;
&lt;td&gt;+15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;SoftBank (PL)&lt;/td&gt;
&lt;td&gt;91W (1st)&lt;/td&gt;
&lt;td&gt;75W (2nd)&lt;/td&gt;
&lt;td&gt;-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;Buffaloes (PL)&lt;/td&gt;
&lt;td&gt;63W (5th)&lt;/td&gt;
&lt;td&gt;78W (1st)&lt;/td&gt;
&lt;td&gt;+15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overestimates bottom teams, underestimates top teams (regression to mean)&lt;/li&gt;
&lt;li&gt;Can't predict collapses (2024 Buffaloes: defending champions → 5th place)&lt;/li&gt;
&lt;li&gt;Foreign player impact not captured when all treated as average&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Did the 2026 Standings Change?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Central League — Tigers Runaway Disappears, 4-Team Deadlock
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Marcel&lt;/th&gt;
&lt;th&gt;Bayesian&lt;/th&gt;
&lt;th&gt;Diff&lt;/th&gt;
&lt;th&gt;P(Pennant)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tigers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80.1W (1st)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;71.5W (1st)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-8.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;26.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Giants&lt;/td&gt;
&lt;td&gt;70.7W (3rd)&lt;/td&gt;
&lt;td&gt;71.1W (2nd)&lt;/td&gt;
&lt;td&gt;+0.4&lt;/td&gt;
&lt;td&gt;20.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dragons&lt;/td&gt;
&lt;td&gt;68.8W (5th)&lt;/td&gt;
&lt;td&gt;71.0W (3rd)&lt;/td&gt;
&lt;td&gt;+2.2&lt;/td&gt;
&lt;td&gt;21.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BayStars&lt;/td&gt;
&lt;td&gt;71.3W (2nd)&lt;/td&gt;
&lt;td&gt;70.7W (4th)&lt;/td&gt;
&lt;td&gt;-0.6&lt;/td&gt;
&lt;td&gt;20.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carp&lt;/td&gt;
&lt;td&gt;70.4W (4th)&lt;/td&gt;
&lt;td&gt;69.1W (5th)&lt;/td&gt;
&lt;td&gt;-1.3&lt;/td&gt;
&lt;td&gt;12.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swallows&lt;/td&gt;
&lt;td&gt;64.3W (6th)&lt;/td&gt;
&lt;td&gt;61.2W (6th)&lt;/td&gt;
&lt;td&gt;-3.1&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Tigers dropped from 80.1W to 71.5W (-8.6).&lt;/strong&gt; Skill corrections pulled them down. Giants at 71.1W even after losing Okamoto to MLB. &lt;strong&gt;Four teams within 0.8 wins&lt;/strong&gt; — Tigers 26%, Dragons 21%, Giants 20%, BayStars 20%. Swallows at 61.2W (78% last place) after Murakami's MLB departure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pacific League — Lions Surge
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Marcel&lt;/th&gt;
&lt;th&gt;Bayesian&lt;/th&gt;
&lt;th&gt;Diff&lt;/th&gt;
&lt;th&gt;P(Pennant)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hawks&lt;/td&gt;
&lt;td&gt;80.5W (1st)&lt;/td&gt;
&lt;td&gt;81.3W (1st)&lt;/td&gt;
&lt;td&gt;+0.8&lt;/td&gt;
&lt;td&gt;47.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fighters&lt;/td&gt;
&lt;td&gt;76.8W (2nd)&lt;/td&gt;
&lt;td&gt;79.1W (2nd)&lt;/td&gt;
&lt;td&gt;+2.3&lt;/td&gt;
&lt;td&gt;27.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buffaloes&lt;/td&gt;
&lt;td&gt;73.8W (3rd)&lt;/td&gt;
&lt;td&gt;77.5W (3rd)&lt;/td&gt;
&lt;td&gt;+3.7&lt;/td&gt;
&lt;td&gt;17.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lions&lt;/td&gt;
&lt;td&gt;68.6W (4th)&lt;/td&gt;
&lt;td&gt;74.9W (4th)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+6.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eagles&lt;/td&gt;
&lt;td&gt;65.5W (5th)&lt;/td&gt;
&lt;td&gt;66.7W (5th)&lt;/td&gt;
&lt;td&gt;+1.2&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marines&lt;/td&gt;
&lt;td&gt;67.1W (6th)&lt;/td&gt;
&lt;td&gt;64.9W (6th)&lt;/td&gt;
&lt;td&gt;-2.2&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lions +6.3 wins&lt;/strong&gt; — foreign player projections offsetting Imai's MLB departure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreign players&lt;/td&gt;
&lt;td&gt;All league-average&lt;/td&gt;
&lt;td&gt;24 individual projections from prior-league stats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill metrics&lt;/td&gt;
&lt;td&gt;Not used&lt;/td&gt;
&lt;td&gt;K%/BB%/BABIP corrections on Marcel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uncertainty&lt;/td&gt;
&lt;td&gt;None (point estimates)&lt;/td&gt;
&lt;td&gt;80%/95% credible intervals on every prediction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team standings&lt;/td&gt;
&lt;td&gt;Single number&lt;/td&gt;
&lt;td&gt;10,000 Monte Carlo sims with pennant probabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;Marcel MAE 0.050&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.0498&lt;/strong&gt; (97% probability of improvement)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The accuracy gain is modest, but "foreign players are no longer invisible," "MLB departures are reflected," and "every prediction comes with uncertainty" meaningfully changed the standings picture. The CL went from "Tigers runaway" to a four-team deadlock.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caveat: Data Limitations
&lt;/h3&gt;

&lt;p&gt;During this work, I discovered that &lt;strong&gt;players who moved to MLB (Murakami, Okamoto)&lt;/strong&gt; were still included in the team simulation — the roster filter only existed in the Streamlit display layer, not in the CSV generation pipeline. Fixed and regenerated, but &lt;strong&gt;there may be other oversights I haven't caught.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a personal project without professional-grade QA. The data is best treated as automated model output, not authoritative predictions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard&lt;/strong&gt;: &lt;a href="https://npb-prediction.streamlit.app/" rel="noopener noreferrer"&gt;npb-prediction.streamlit.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/yasumorishima/npb-prediction" rel="noopener noreferrer"&gt;github.com/yasumorishima/npb-prediction&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Data Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://baseball-data.com" rel="noopener noreferrer"&gt;Baseball Data Freak&lt;/a&gt; — NPB player stats&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://npb.jp" rel="noopener noreferrer"&gt;NPB Official&lt;/a&gt; — Official records&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>baseball</category>
      <category>python</category>
      <category>bayesian</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
