<?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: Billy Girboux</title>
    <description>The latest articles on DEV Community by Billy Girboux (@geekourson).</description>
    <link>https://dev.to/geekourson</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%2F4087118%2Fcca3b1a0-b44d-4b26-8cc5-7623797396fc.jpg</url>
      <title>DEV Community: Billy Girboux</title>
      <link>https://dev.to/geekourson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/geekourson"/>
    <language>en</language>
    <item>
      <title>I trained a 142M language model from scratch to play chess. You can play it right now</title>
      <dc:creator>Billy Girboux</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:34:18 +0000</pubDate>
      <link>https://dev.to/geekourson/i-trained-a-142m-language-model-from-scratch-to-play-chess-you-can-play-it-right-now-3g9m</link>
      <guid>https://dev.to/geekourson/i-trained-a-142m-language-model-from-scratch-to-play-chess-you-can-play-it-right-now-3g9m</guid>
      <description>&lt;p&gt;A few weeks ago I trained a language model from scratch to play chess. Not fine-tuned, not prompted. A plain transformer decoder in PyTorch, 20 layers of width 768, trained on nothing but sequences of moves, on a single RTX 3090.&lt;/p&gt;

&lt;p&gt;It plays rated games on Lichess and it will take a challenge from anyone:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://lichess.org/@/philidor-142M" rel="noopener noreferrer"&gt;lichess.org/@/philidor-142M&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Around 1685 in rapid and bullet, across 950+ rated games, and not a single illegal move in any of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  One move, one token
&lt;/h2&gt;

&lt;p&gt;The design decision everything else follows from: there is no BPE, no subword vocabulary.&lt;/p&gt;

&lt;p&gt;There are exactly &lt;strong&gt;1971 tokens&lt;/strong&gt;. Three specials (&lt;code&gt;&amp;lt;pad&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;bos&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;eos&amp;gt;&lt;/code&gt;) and &lt;strong&gt;1968 legal move shapes&lt;/strong&gt; in UCI notation, every geometrically possible from-square to to-square pair, promotions included.&lt;/p&gt;

&lt;p&gt;So a game is not a string. It is a sequence of tokens, one per half-move, and a 200-move game is a 200-token sequence. That is why the model answers in ~23 ms regardless of how long the game has been running, while a general model has to re-read a growing text prompt.&lt;/p&gt;
&lt;h2&gt;
  
  
  The legality mask is one line
&lt;/h2&gt;

&lt;p&gt;The model, left alone, occasionally proposes an impossible move. In free generation it is legal &lt;strong&gt;98.85%&lt;/strong&gt; of the time. Good, and disqualifying: a UCI engine that returns one illegal move loses the game on the spot.&lt;/p&gt;

&lt;p&gt;The fix is a single masked softmax over the legal moves in the current position:&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;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vocab_size&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="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&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;move&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;board&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;legal_moves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stoi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uci&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;idx&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;masked_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One forward pass, deterministic, and it preserves the model's ordering among legal moves. That is the whole engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering mattered more than architecture
&lt;/h2&gt;

&lt;p&gt;I read &lt;strong&gt;89,288,421&lt;/strong&gt; games from the Lichess archives and kept &lt;strong&gt;11,035,777&lt;/strong&gt;. A 12.4% retention rate.&lt;/p&gt;

&lt;p&gt;Everything else follows from that: 790M move tokens, and about 3.2 billion tokens seen during training. A model learns the distribution you show it, and most of what is available is fast games between weak players.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps that had nothing to do with chess
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The lazy generator that was not lazy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parsing 89 million games in parallel looked like this, and it looks correct:&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;games&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&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;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# a generator, nothing computed yet
&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;pool&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;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imap_unordered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;games&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Pool.imap&lt;/code&gt; drains its input as fast as it can, queueing every task up front. On 89 million games that is about 100 GB of text held in RAM, on a machine that has 31. The generator's laziness is completely cancelled by an eager consumer downstream.&lt;/p&gt;

&lt;p&gt;The fix is to hand it fixed-size windows instead of the whole stream. Measured footprint after the change: 2.8 GB, flat from start to finish.&lt;/p&gt;

&lt;p&gt;The general form of this bug: &lt;strong&gt;a careful component loses all its care as soon as an impatient one plugs into it.&lt;/strong&gt; The part is not at fault, the assembly is, and that is exactly what you cannot see by rereading your own code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The train/validation split that leaks in silence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious way to split a token stream:&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;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt; &lt;span class="o"&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;tokens&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;   &lt;span class="c1"&gt;# looks reasonable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It cuts a document in half. The model saw the beginning during training, and you are now asking it to predict the rest as if it were new. Your validation loss improves, your curves look great, and there is no visible symptom at all.&lt;/p&gt;

&lt;p&gt;I split by whole game instead: 10,925,420 games for training, 110,357 for validation, appearing nowhere else.&lt;/p&gt;

&lt;p&gt;It is the same leak as a time-based split that lets the future through, or a random split that separates two rows belonging to the same user. It never shows up in a metric. It has to be reasoned about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything is public
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code&lt;/strong&gt;: &lt;a href="https://github.com/geekourson/philidor" rel="noopener noreferrer"&gt;github.com/geekourson/philidor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models&lt;/strong&gt;: &lt;a href="https://huggingface.co/billygeekourson" rel="noopener noreferrer"&gt;huggingface.co/billygeekourson&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full write-up&lt;/strong&gt;, with every command and its real output: &lt;a href="https://www.billygirboux.fr/en/blog/modele-ia-echecs-weekend" rel="noopener noreferrer"&gt;billygirboux.fr&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer anything about training small models on consumer hardware, or about the parts that turned out to be plain data engineering.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>showdev</category>
      <category>chess</category>
    </item>
  </channel>
</rss>
