<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="mchav.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="mchav.github.io/" rel="alternate" type="text/html" /><updated>2026-04-25T02:38:46+00:00</updated><id>mchav.github.io/feed.xml</id><title type="html">Michael Chavinda</title><subtitle>A collection of my thoughts on the various topics I find myself interested in.</subtitle><entry><title type="html">A first look at token efficiency</title><link href="mchav.github.io/a-first-look-at-token-efficiency/" rel="alternate" type="text/html" title="A first look at token efficiency" /><published>2026-04-24T00:00:00+00:00</published><updated>2026-04-24T00:00:00+00:00</updated><id>mchav.github.io/a-first-look-at-token-efficiency</id><content type="html" xml:base="mchav.github.io/a-first-look-at-token-efficiency/"><![CDATA[<p>A while ago I saw the article <a href="https://martinalderson.com/posts/which-programming-languages-are-most-token-efficient/">Which languages are most token efficient</a>. The article was largely discredited since it didn’t have a clear methodology. Unfortunately, I haven’t come up with one yet but I thought to do a small, anecdotal experiment comparing code generation in Haskell notebooks (with Sabela) vs Python notebooks (Marimo). Notebooks are a great environment to use with agents since they are modular by design and you can create great agent APIs on top of them. It’s also much easier to intervene as the agent is working.</p>

<p>My thoughts going into this were that Haskell would be more efficient because the compiler guidance would keep the AI in check as it explored. A sort of neuro-symbolic search constraint. But the ubiquity of Python, despite it being a dynamic language, means that it’s easier for LLMs to do something akin to one shotting the code.</p>

<p>Most of the churn seems to be the LLM trying to lookup how to do pretty simple things in Haskell. Whereas when working with Marimo pair it gets straight to the task almost without thinking about it (about 200 tokens in vs a couple of thousand in Haskell). As a result, it’s probably “cheaper” overall to use Python for arbitrary tasks at this point in time.</p>

<p>That said, the churn of the trial and error of dynamic languages might flip the economics for much larger projects. That’s still an open question that requires more thorough investigation. As LLMs get “better” and more software is written in more esoteric languages the gap might close. Or, more ambitiously, the gap might close if language communities trained and evaluated open source models (ethically) that are good at navigating their specific ecosystems.</p>

<p>You can see how the LLM solves each task in the videos below.</p>

<p><a href="https://youtu.be/JVDTYm4aYIE" title="Sabela + Claude Code integration"><img src="https://img.youtube.com/vi/JVDTYm4aYIE/0.jpg" alt="Sabela notebook using Claude" /></a></p>

<p><a href="https://youtu.be/8aaTm3FS4Eg" title="Marimo pair example"><img src="https://img.youtube.com/vi/8aaTm3FS4Eg/0.jpg" alt="Marimo pair using Claude" /></a></p>

<p>This investigation will continue more systematically in subsequent posts but I think as small as this example was it’s pretty telling.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A while ago I saw the article Which languages are most token efficient. The article was largely discredited since it didn’t have a clear methodology. Unfortunately, I haven’t come up with one yet but I thought to do a small, anecdotal experiment comparing code generation in Haskell notebooks (with Sabela) vs Python notebooks (Marimo). Notebooks are a great environment to use with agents since they are modular by design and you can create great agent APIs on top of them. It’s also much easier to intervene as the agent is working.]]></summary></entry><entry><title type="html">Pandas feels clunky coming from R. What about Haskell?</title><link href="mchav.github.io/being-less-clunky/" rel="alternate" type="text/html" title="Pandas feels clunky coming from R. What about Haskell?" /><published>2026-04-18T00:00:00+00:00</published><updated>2026-04-18T00:00:00+00:00</updated><id>mchav.github.io/being-less-clunky</id><content type="html" xml:base="mchav.github.io/being-less-clunky/"><![CDATA[<p>Some years ago I came across an issue in the <a href="https://github.com/acowley/Frames/issues/185">Frames repo</a> that mentioned a blog post titled “<a href="https://www.sumsar.net/blog/pandas-feels-clunky-when-coming-from-r/">Why pandas feels clunky when coming from R.</a>” The article showed a side-by-side of simple data exploration in R and compared the code to Pandas. At the time, the author concluded that Pandas was “clunkier” than R. The author operationalises the definition of clunkiness but I think it’s really more of a you-know-it-when-you-see-it thing. You can feel if an API is making you drift further away from your task and making you think more about the tool and its idiosyncracies.</p>

<p>So let’s give the example a spin and see if Haskell feels clunky compared to R.</p>

<p>NB: Polars isn’t clunky and you should probably use that instead of Pandas but we compare to Pandas here because the original author does.</p>

<h2 id="reading-the-data">Reading the data</h2>

<p>According to the article, we’ve been given a table of purchases from various countries, where the customer could have received a discount. We want to answer some questions about the data but we do so incrementally. The blog post showcases the ease of writing and changing business logic with dataframes.</p>

<p>So we start by looking at the total amount gathered from sales.</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">library</span><span class="p">(</span><span class="n">tidyverse</span><span class="p">)</span><span class="w">

</span><span class="n">purchases</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="n">read_csv</span><span class="p">(</span><span class="s2">"purchases.csv"</span><span class="p">)</span><span class="w">
</span><span class="n">purchases</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="n">head</span><span class="p">()</span><span class="w">
</span></code></pre></div></div>

<p>With dataframe (in a cabal script) things look roughly similar give or take a few lines of code.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">#!/</span><span class="n">usr</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">env</span> <span class="n">cabal</span>
<span class="cm">{- cabal:
  build-depends: base &gt;= 4, dataframe
-}</span>
<span class="kr">module</span> <span class="nn">Main</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame</span> <span class="k">as</span> <span class="n">D</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
    <span class="n">df</span> <span class="o">&lt;-</span> <span class="kt">D</span><span class="o">.</span><span class="n">readCsv</span> <span class="s">"./data/purchases.csv"</span>
    <span class="n">print</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">take</span> <span class="mi">10</span> <span class="n">df</span><span class="p">)</span>
</code></pre></div></div>

<p>Pandas looks pretty similar:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>

<span class="n">purchases</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">read_csv</span><span class="p">(</span><span class="s">"purchases.csv"</span><span class="p">)</span>
<span class="n">purchases</span><span class="p">.</span><span class="n">head</span><span class="p">()</span>
</code></pre></div></div>

<p>And finally in Frames we have:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="cp">{-# LANGUAGE TemplateHaskell #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span>           <span class="nn">Frames</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Foldable</span> <span class="k">as</span> <span class="n">F</span>

<span class="n">tableTypes</span> <span class="s">"Purchases"</span> <span class="s">"purchases.csv"</span>

<span class="n">loadBenchmarks</span> <span class="o">::</span> <span class="kt">IO</span> <span class="p">(</span><span class="kt">Frame</span> <span class="kt">Purchases</span><span class="p">)</span>
<span class="n">loadBenchmarks</span> <span class="o">=</span> <span class="n">inCoreAoS</span> <span class="p">(</span><span class="n">readTable</span> <span class="s">"purchases.csv"</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">ms</span> <span class="o">&lt;-</span> <span class="n">loadBenchmarks</span>
  <span class="n">print</span> <span class="p">(</span><span class="n">take</span> <span class="mi">10</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">toList</span> <span class="n">ms</span><span class="p">))</span>
</code></pre></div></div>

<p>Everyone is doing well so far! We can read the CSV and show the first few rows.</p>

<h2 id="summing-by-amount">Summing by amount</h2>

<p>Now, let’s see how much we made in total sales.</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">purchases</span><span class="o">$</span><span class="n">amount</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> </span><span class="nf">sum</span><span class="p">()</span><span class="w">
</span></code></pre></div></div>

<p>R expresses the logic pretty directly. As does pandas:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">purchases</span><span class="p">[</span><span class="s">"amount"</span><span class="p">].</span><span class="nb">sum</span><span class="p">()</span>
</code></pre></div></div>

<p>For dataframe we would add the following expression:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"amount"</span><span class="p">)</span> <span class="n">df</span><span class="p">)</span>
</code></pre></div></div>

<p>That’s some clunk creeping in. We not only had to get the column name right, we had to remember its type. We can pull out some machinery to make it less clunky.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">#!/</span><span class="n">usr</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">env</span> <span class="n">cabal</span>
<span class="cm">{- cabal:
  build-depends: base &gt;= 4, dataframe
-}</span>
<span class="cp">{-# LANGUAGE TemplateHaskell #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame</span> <span class="k">as</span> <span class="n">D</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame.Functions</span> <span class="k">as</span> <span class="n">F</span>

<span class="o">$</span><span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">declareColumnsFromCsvFile</span> <span class="s">"./data/purchases.csv"</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
    <span class="n">df</span> <span class="o">&lt;-</span> <span class="kt">D</span><span class="o">.</span><span class="n">readCsv</span> <span class="s">"./data/purchases.csv"</span>
    <span class="n">print</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">take</span> <span class="mi">10</span> <span class="n">df</span><span class="p">)</span>
    <span class="n">print</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">sum</span> <span class="n">amount</span> <span class="n">df</span><span class="p">)</span>
</code></pre></div></div>

<p>So we traded off fragility for complexity. For this small example it doesn’t buy us much but in the next part it will.</p>

<p>Frames already had <code class="language-plaintext highlighter-rouge">TemplateHaskell</code> enabled so we just have to add:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="nn">Lens.Micro.Extras</span>
<span class="kr">import</span> <span class="nn">Control.Foldl</span> <span class="p">(</span><span class="nf">fold</span><span class="p">,</span> <span class="nf">sum</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Prelude</span> <span class="k">hiding</span> <span class="p">(</span><span class="nf">sum</span><span class="p">)</span>

<span class="c1">-- All the old code.</span>
    <span class="n">print</span> <span class="o">$</span> <span class="n">fold</span> <span class="n">sum</span> <span class="p">(</span><span class="n">view</span> <span class="n">amount</span> <span class="o">&lt;$&gt;</span> <span class="n">ms</span><span class="p">)</span>
</code></pre></div></div>

<p>This is still pretty readable if you understand FP. In fact it’s a great declarative programming model that shows you what a sum is. For some problems this is great. But as we tack on complexity this will come back to bite us.</p>

<h2 id="grouping-by-country">Grouping by country</h2>

<p>So apparently they wanted the amounts by country. Now you have to go back and change the code. R still looks stunning:</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">purchases</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">group_by</span><span class="p">(</span><span class="n">country</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">summarize</span><span class="p">(</span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">sum</span><span class="p">(</span><span class="n">amount</span><span class="p">))</span><span class="w">
</span></code></pre></div></div>

<p>This has a great SQL-ish API. Python is similar but starts to be a little clunky since it requires you to think about indices:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="n">purchases</span>
  <span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="s">"country"</span><span class="p">)</span>
  <span class="p">.</span><span class="n">agg</span><span class="p">(</span><span class="n">total</span><span class="o">=</span><span class="p">(</span><span class="s">"amount"</span><span class="p">,</span> <span class="s">"sum"</span><span class="p">))</span>
  <span class="p">.</span><span class="n">reset_index</span><span class="p">()</span>
<span class="p">)</span>
</code></pre></div></div>

<p>dataframe’s TemplateHaskell setup now pays off.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="o">$</span>  <span class="n">purchases</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"total"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">sum</span> <span class="n">amount</span><span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">groupBy</code> takes a list of strings. We can recover the column’s string name with <code class="language-plaintext highlighter-rouge">F.name</code>. We also introduce the expression DSL that we use to calculate the sum (instead of the top-level <code class="language-plaintext highlighter-rouge">D.sum</code> function). There’s a little complexity but now we get a SQL-ish API that doesn’t leak that much implementation detail.</p>

<p>Frames starts to bring out the artillery.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# OPTIONS_GHC -Wall            #-}</span>

<span class="cp">{-# LANGUAGE TypeApplications    #-}</span>
<span class="cp">{-# LANGUAGE TemplateHaskell     #-}</span>
<span class="cp">{-# LANGUAGE DataKinds           #-}</span>
<span class="cp">{-# LANGUAGE FlexibleContexts    #-}</span>
<span class="cp">{-# LANGUAGE FlexibleInstances   #-}</span>
<span class="cp">{-# LANGUAGE OverloadedStrings   #-}</span>
<span class="cp">{-# LANGUAGE ExplicitForAll      #-}</span>
<span class="cp">{-# LANGUAGE TypeOperators       #-}</span>
<span class="cp">{-# LANGUAGE ScopedTypeVariables #-}</span>
<span class="cp">{-# LANGUAGE TypeFamilies        #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span>           <span class="nn">Frames</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Foldable</span> <span class="k">as</span> <span class="n">F</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.List</span> <span class="k">as</span> <span class="n">L</span>
<span class="kr">import</span>           <span class="nn">Lens.Micro.Extras</span>

<span class="kr">import</span>           <span class="nn">Control.Foldl</span> <span class="p">(</span><span class="nf">fold</span><span class="p">,</span> <span class="nf">sum</span><span class="p">)</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Control.Foldl</span> <span class="k">as</span> <span class="n">Foldl</span>
<span class="kr">import</span>           <span class="nn">Prelude</span> <span class="k">hiding</span> <span class="p">(</span><span class="nf">sum</span><span class="p">)</span>

<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Frames.MapReduce</span> <span class="k">as</span> <span class="n">FMR</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Frames.Folds</span> <span class="k">as</span> <span class="n">FF</span>

<span class="n">tableTypes</span> <span class="s">"Purchases"</span> <span class="s">"purchases.csv"</span>

<span class="n">loadBenchmarks</span> <span class="o">::</span> <span class="kt">IO</span> <span class="p">(</span><span class="kt">Frame</span> <span class="kt">Purchases</span><span class="p">)</span>
<span class="n">loadBenchmarks</span> <span class="o">=</span> <span class="n">inCoreAoS</span> <span class="p">(</span><span class="n">readTable</span> <span class="s">"purchases.csv"</span><span class="p">)</span>

<span class="n">unpack</span> <span class="o">::</span> <span class="kt">FMR</span><span class="o">.</span><span class="kt">Unpack</span> <span class="kt">Purchases</span> <span class="kt">Purchases</span>
<span class="n">unpack</span> <span class="o">=</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">unpackFilterOnField</span> <span class="o">@</span><span class="kt">Country</span> <span class="p">(</span><span class="n">const</span> <span class="kt">True</span><span class="p">)</span>

<span class="n">assign</span> <span class="o">::</span> <span class="kt">FMR</span><span class="o">.</span><span class="kt">Assign</span> <span class="p">(</span><span class="kt">Record</span> <span class="n">'</span><span class="p">[</span><span class="kt">Country</span><span class="p">])</span> <span class="kt">Purchases</span> <span class="p">(</span><span class="kt">Record</span> <span class="n">'</span><span class="p">[</span><span class="kt">Amount</span><span class="p">,</span> <span class="kt">Discount</span><span class="p">])</span>
<span class="n">assign</span> <span class="o">=</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">splitOnKeys</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="kt">Country</span><span class="p">]</span>

<span class="n">reduce</span> <span class="o">::</span> <span class="kt">FMR</span><span class="o">.</span><span class="kt">Reduce</span> <span class="p">(</span><span class="kt">Record</span> <span class="n">'</span><span class="p">[</span><span class="kt">Country</span><span class="p">])</span>
                     <span class="p">(</span><span class="kt">Record</span> <span class="n">'</span><span class="p">[</span><span class="kt">Amount</span><span class="p">,</span> <span class="kt">Discount</span><span class="p">])</span>
                     <span class="p">(</span><span class="kt">Frame</span> <span class="kt">Purchases</span><span class="p">)</span>
<span class="n">reduce</span> <span class="o">=</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">foldAndAddKey</span> <span class="o">$</span> <span class="p">(</span><span class="kt">FF</span><span class="o">.</span><span class="n">foldAllConstrained</span> <span class="o">@</span><span class="kt">Num</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="kt">Amount</span><span class="p">,</span> <span class="kt">Discount</span><span class="p">])</span> <span class="n">sum</span>

<span class="n">mrFold</span> <span class="o">::</span> <span class="kt">FMR</span><span class="o">.</span><span class="kt">Fold</span> <span class="kt">Purchases</span> <span class="p">(</span><span class="kt">Frame</span> <span class="kt">Purchases</span><span class="p">)</span>
<span class="n">mrFold</span> <span class="o">=</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">concatFold</span> <span class="o">$</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">mapReduceFold</span> <span class="n">unpack</span> <span class="n">assign</span> <span class="n">reduce</span>

<span class="n">rhead</span> <span class="o">::</span> <span class="kt">Show</span> <span class="n">a</span> <span class="o">=&gt;</span> <span class="kt">Frame</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">rhead</span>  <span class="o">=</span> <span class="nf">\</span><span class="n">ms</span> <span class="o">-&gt;</span> <span class="n">mapM_</span> <span class="n">print</span> <span class="p">(((</span><span class="n">take</span> <span class="mi">6</span><span class="p">)</span> <span class="o">.</span> <span class="kt">F</span><span class="o">.</span><span class="n">toList</span><span class="p">)</span> <span class="n">ms</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">ms</span> <span class="o">&lt;-</span> <span class="n">loadBenchmarks</span>
  <span class="n">rhead</span> <span class="n">ms</span>
  <span class="n">print</span> <span class="o">$</span> <span class="n">fold</span> <span class="n">sum</span> <span class="p">(</span><span class="n">view</span> <span class="n">amount</span> <span class="o">&lt;$&gt;</span> <span class="n">ms</span><span class="p">)</span>
  <span class="kr">let</span> <span class="n">result</span> <span class="o">=</span> <span class="kt">FMR</span><span class="o">.</span><span class="n">fold</span> <span class="n">mrFold</span> <span class="n">ms</span>
  <span class="n">putStrLn</span> <span class="o">$</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">intercalate</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span> <span class="o">$</span> <span class="n">fmap</span> <span class="n">show</span> <span class="o">$</span> <span class="n">fold</span> <span class="kt">Foldl</span><span class="o">.</span><span class="n">list</span> <span class="n">result</span><span class="p">)</span>
</code></pre></div></div>

<p>At the points at which computation happens it’s pretty declarative but simple logic is now overwhelmed by boilerplate. Refactoring to answer a pretty easy follow up question cost us a lot in code but we get compile-time safety. At this stage, how much complexity you’re willing to stomach is a matter of person taste and familiarity.</p>

<h2 id="deducting-the-discount">Deducting the discount</h2>
<p>Frames is now clunky so we’ll stop reporting on it. We now want to get the sum of not just the amount but the amount minus the discount. Let’s see how the other implementations handle the discount.</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># R</span><span class="w">
</span><span class="n">purchases</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> 
  </span><span class="n">group_by</span><span class="p">(</span><span class="n">country</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> 
  </span><span class="n">summarize</span><span class="p">(</span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">sum</span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">discount</span><span class="p">))</span><span class="w">
</span></code></pre></div></div>

<p>The R change is pretty easy. Pandas gets a little weird:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="n">purchases</span>
  <span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="s">"country"</span><span class="p">)</span>
  <span class="p">.</span><span class="nb">apply</span><span class="p">(</span><span class="k">lambda</span> <span class="n">df</span><span class="p">:</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"amount"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"discount"</span><span class="p">]).</span><span class="nb">sum</span><span class="p">())</span>
  <span class="p">.</span><span class="n">reset_index</span><span class="p">()</span>
  <span class="p">.</span><span class="n">rename</span><span class="p">(</span><span class="n">columns</span><span class="o">=</span><span class="p">{</span><span class="mi">0</span><span class="p">:</span> <span class="s">"total"</span><span class="p">})</span>
<span class="p">)</span>
</code></pre></div></div>

<p>We now start leaking a few more implementation details. Column renaming and the difference between simple sums and expression sums.</p>

<p>Haskell still tracks R:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="o">$</span>  <span class="n">purchases</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"total"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="n">amount</span> <span class="o">-</span> <span class="n">discount</span><span class="p">)]</span>
</code></pre></div></div>

<h2 id="removing-outliers">Removing outliers</h2>

<p>Let’s now remove any rows whose amount is greater than the global median * 10.</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">purchases</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">filter</span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="n">median</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="m">10</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">group_by</span><span class="p">(</span><span class="n">country</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w"> 
  </span><span class="n">summarize</span><span class="p">(</span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">sum</span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">discount</span><span class="p">))</span><span class="w">
</span></code></pre></div></div>

<p>Pandas introduces a <code class="language-plaintext highlighter-rouge">query</code> function:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="n">purchases</span>
  <span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s">"amount &lt;= amount.median() * 10"</span><span class="p">)</span>
  <span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="s">"country"</span><span class="p">)</span>
  <span class="p">.</span><span class="nb">apply</span><span class="p">(</span><span class="k">lambda</span> <span class="n">df</span><span class="p">:</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"amount"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"discount"</span><span class="p">]).</span><span class="nb">sum</span><span class="p">())</span>
  <span class="p">.</span><span class="n">reset_index</span><span class="p">()</span>
  <span class="p">.</span><span class="n">rename</span><span class="p">(</span><span class="n">columns</span><span class="o">=</span><span class="p">{</span><span class="mi">0</span><span class="p">:</span> <span class="s">"total"</span><span class="p">})</span>
<span class="p">)</span>
</code></pre></div></div>

<p>dataframe looks fairly similar too:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="o">$</span>  <span class="n">purchases</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">filterWhere</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">toDouble</span> <span class="n">amount</span> <span class="o">.&lt;=</span> <span class="kt">F</span><span class="o">.</span><span class="n">median</span> <span class="n">amount</span> <span class="o">*</span> <span class="mi">10</span><span class="p">)</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"total"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="n">amount</span> <span class="o">-</span> <span class="n">discount</span><span class="p">)]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">amount</code> is an Int and median is a double so we pay a small conversion cost but that’s okay.</p>

<p>Things look good on all fronts. The refactor wasn’t hard.</p>

<h2 id="using-the-median-within-each-country">Using the median within each country</h2>

<p>R just drops the comparison to after the groupBy.</p>

<div class="language-R highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">purchases</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">group_by</span><span class="p">(</span><span class="n">country</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">filter</span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="n">median</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="m">10</span><span class="p">)</span><span class="w"> </span><span class="o">|&gt;</span><span class="w">
  </span><span class="n">summarize</span><span class="p">(</span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">sum</span><span class="p">(</span><span class="n">amount</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">discount</span><span class="p">))</span><span class="w">
</span></code></pre></div></div>

<p>Pandas struggles to handle this logic succintly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="n">purchases</span>
  <span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="s">"country"</span><span class="p">)</span>
  <span class="p">.</span><span class="nb">apply</span><span class="p">(</span><span class="k">lambda</span> <span class="n">df</span><span class="p">:</span> <span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">[</span><span class="s">"amount"</span><span class="p">]</span> <span class="o">&lt;=</span> <span class="n">df</span><span class="p">[</span><span class="s">"amount"</span><span class="p">].</span><span class="n">median</span><span class="p">()</span> <span class="o">*</span> <span class="mi">10</span><span class="p">])</span>
  <span class="p">.</span><span class="n">reset_index</span><span class="p">(</span><span class="n">drop</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
  <span class="p">.</span><span class="n">groupby</span><span class="p">(</span><span class="s">"country"</span><span class="p">)</span>
  <span class="p">.</span><span class="nb">apply</span><span class="p">(</span><span class="k">lambda</span> <span class="n">df</span><span class="p">:</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"amount"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"discount"</span><span class="p">]).</span><span class="nb">sum</span><span class="p">())</span>
  <span class="p">.</span><span class="n">reset_index</span><span class="p">()</span>
  <span class="p">.</span><span class="n">rename</span><span class="p">(</span><span class="n">columns</span><span class="o">=</span><span class="p">{</span><span class="mi">0</span><span class="p">:</span> <span class="s">"total"</span><span class="p">})</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The diff now includes a lot of extra noise and takes you away fro the core change.</p>

<p>Haskell keeps the diff minimal:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="o">$</span>  <span class="n">purchases</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">filterWhere</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">toDouble</span> <span class="n">amount</span> <span class="o">.&lt;=</span> <span class="kt">F</span><span class="o">.</span><span class="n">over</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">median</span> <span class="n">amount</span> <span class="o">*</span> <span class="mi">10</span><span class="p">))</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"total"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="n">amount</span> <span class="o">-</span> <span class="n">discount</span><span class="p">)]</span>
</code></pre></div></div>

<p>We use the <code class="language-plaintext highlighter-rouge">over</code> expression which does an intra-expression grouping. All the setup we did is now paying off. Our diffs are small and focused. Arguably, the R diff (changing the order of the groupby/filter) makes things a little more confusing because your reasoning is non-local. You need to read the previous line to understand what</p>

<h2 id="what-about-type-safety">What about type safety?</h2>

<p>We lost some type safety by making the API less clunky. For exmaple, this gives us a runtime failure.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">print</span> <span class="o">$</span>  <span class="n">purchases</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">exclude</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">amount</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">filterWhere</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">toDouble</span> <span class="n">amount</span> <span class="o">.&lt;=</span> <span class="kt">F</span><span class="o">.</span><span class="n">over</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">median</span> <span class="n">amount</span> <span class="o">*</span> <span class="mi">10</span><span class="p">))</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">country</span><span class="p">]</span>
      <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"total"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="n">amount</span> <span class="o">-</span> <span class="n">discount</span><span class="p">)]</span>
</code></pre></div></div>

<p>Filter will try and refer to an amount that doesn’t exist then throw an exception. Frames would never let us do something like this. Can we get type safety back?</p>

<p>Yes we can.</p>

<p>We just have to swap out some imports and do a little more annotating.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">#!/</span><span class="n">usr</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">env</span> <span class="n">cabal</span>
<span class="cm">{- cabal:
  build-depends: base &gt;= 4, dataframe, text
-}</span>
<span class="cp">{-# LANGUAGE DataKinds #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame</span> <span class="k">as</span> <span class="n">D</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame.Typed</span> <span class="k">as</span> <span class="n">DT</span>

<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
    <span class="n">df</span> <span class="o">&lt;-</span> <span class="kt">D</span><span class="o">.</span><span class="n">readCsv</span> <span class="s">"./data/purchases.csv"</span>
    <span class="kr">let</span> <span class="n">tdf</span> <span class="o">=</span> <span class="kt">DT</span><span class="o">.</span><span class="n">unsafeFreeze</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span> <span class="kt">DT</span><span class="o">.</span><span class="kt">Column</span> <span class="s">"country"</span> <span class="kt">Text</span>
                                <span class="p">,</span> <span class="kt">DT</span><span class="o">.</span><span class="kt">Column</span> <span class="s">"amount"</span> <span class="kt">Int</span>
                                <span class="p">,</span> <span class="kt">DT</span><span class="o">.</span><span class="kt">Column</span> <span class="s">"discount"</span> <span class="kt">Int</span><span class="p">]</span> <span class="n">df</span>

    <span class="n">print</span> <span class="o">$</span>  <span class="n">tdf</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">filterWhere</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">toDouble</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"amount"</span><span class="p">)</span> <span class="o">.&lt;=</span> <span class="kt">DT</span><span class="o">.</span><span class="n">over</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"country"</span><span class="p">]</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">median</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"amount"</span> <span class="o">*</span> <span class="mi">10</span><span class="p">))</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"country"</span><span class="p">]</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">agg</span> <span class="o">@</span><span class="s">"total"</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"amount"</span> <span class="o">-</span> <span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"discount"</span><span class="p">)</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggNil</span><span class="p">))</span>
</code></pre></div></div>

<p>We could use template Haskell to generate the type we freeze the dataframe into.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- this is the equivalent of writing:</span>
<span class="c1">-- type Purchases = '[ DT.Column "country" Text</span>
<span class="c1">--                   , DT.Column "amount" Int</span>
<span class="c1">--                   , DT.Column "discount" Int]</span>
<span class="o">$</span><span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">deriveSchemaFromCsvFile</span> <span class="s">"Purchases"</span> <span class="s">"./data/purchases.csv"</span><span class="p">)</span>


<span class="c1">-- Skip other code</span>
    <span class="kr">let</span> <span class="n">tdf</span> <span class="o">=</span> <span class="kt">DT</span><span class="o">.</span><span class="n">unsafeFreeze</span> <span class="o">@</span><span class="kt">Purchases</span> <span class="n">df</span>
</code></pre></div></div>

<p>We’re only a little clunkier than before. We could in principle add more TemplateHaskell to get the column references similar to what we did to remove <code class="language-plaintext highlighter-rouge">F.col</code> before. Leaving us with:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Some code above.</span>
    <span class="n">print</span> <span class="o">$</span>  <span class="n">tdf</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">filterWhere</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">toDouble</span> <span class="n">amount</span> <span class="o">.&lt;=</span> <span class="kt">DT</span><span class="o">.</span><span class="n">over</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"country"</span><span class="p">]</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">median</span> <span class="p">(</span><span class="n">amount</span> <span class="o">*</span> <span class="mi">10</span><span class="p">)))</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"country"</span><span class="p">]</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">agg</span> <span class="o">@</span><span class="s">"total"</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">sum</span> <span class="p">(</span><span class="n">amount</span> <span class="o">-</span> <span class="n">discount</span><span class="p">)</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggNil</span><span class="p">))</span>
</code></pre></div></div>

<h2 id="up-shot">Up shot</h2>
<p>Safety and usability are usually at tension. But you can use a compiled language (in this case Haskell) as ergonomically as you would a dynamic language if you design the right abstractions and keep them centered on user tasks.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Some years ago I came across an issue in the Frames repo that mentioned a blog post titled “Why pandas feels clunky when coming from R.” The article showed a side-by-side of simple data exploration in R and compared the code to Pandas. At the time, the author concluded that Pandas was “clunkier” than R. The author operationalises the definition of clunkiness but I think it’s really more of a you-know-it-when-you-see-it thing. You can feel if an API is making you drift further away from your task and making you think more about the tool and its idiosyncracies.]]></summary></entry><entry><title type="html">Grow and mow: interpretable models with boosting, symbolic regression and e-graphs</title><link href="mchav.github.io/grow-and-mow/" rel="alternate" type="text/html" title="Grow and mow: interpretable models with boosting, symbolic regression and e-graphs" /><published>2026-04-09T00:00:00+00:00</published><updated>2026-04-09T00:00:00+00:00</updated><id>mchav.github.io/grow-and-mow</id><content type="html" xml:base="mchav.github.io/grow-and-mow/"><![CDATA[<p>This post is the convergence of two ideas that have been floating in my head for about a year. Can we learn messy stochastic models and use algorithmic/algebraic tools to rein in model complexity to make models interpretable?</p>

<p>A notebook with a visual TLDR is available <a href="https://mchav.github.io/static/boosted_symbolic_regression.html">as an export of the marimo notebook</a>. You can import the <a href="https://gist.github.com/mchav/b3414b907c8e790d8d1ece791a2d73b9">raw notebook</a> into <a href="molab.marimo.io">molab</a> to try it out yourself.</p>

<p>The first idea comes from a problem I kept running into at work.</p>

<h2 id="boosting">Boosting</h2>

<p>I spent most of my career working in fraud detection. XGBoost (or similar techniques) dominate in this space. XGBoost approximates a function by training small decision trees to incrementally chip away at a residual. Each tree doesn’t do a great job on its own, but they pick up each other’s slack by collectively contributing towards the whole picture. There’s an old parable about blind men touching different parts of an elephant. One feels the trunk and says it’s a snake, another feels the leg and says it’s a tree, a third feels the side and says it’s a wall. Boosting works like this. Each tree touches a different part of the function and gets a partial answer. Their sum reaches the right conclusion. (There is a better treatment of boosting in general <a href="https://explained.ai/gradient-boosting/L2-loss.html">here</a>.)</p>

<p>But similar to the parable, the trees end up with a lot of detail that’s extraneous to the underlying function. Each individual tree tells you little about the final prediction. The ensemble is accurate but a little opaque. You can use game-theoretic techniques like <a href="https://shap.readthedocs.io/">SHAP</a> to determine what each feature contributes to the output, and that gives you a coarse view of what the model is doing. But the most interpretable form a model can come in is a single tree. That’s just nested if-statements. You wouldn’t have to probe the model in creative ways. You could follow the lines of implication and see exactly why a decision was made.</p>

<p>I like this human-centric way of building models. The model is something to be understood, changed, questioned, inspected. There are practical benefits too. If you can translate a model into a single decision tree, you can translate it to SQL, to Go, to whatever your production environment uses. You’re not locked into serving the model in the framework it was trained in.</p>

<p>So how do you go from an ensemble of hundreds of trees to a single tree?</p>

<p>Turns out that problem is NP-hard. A compact tree reconstructed from an ensemble is called a <a href="https://arxiv.org/abs/2003.11132">born-again tree</a>. The technique uses dynamic programming to regenerate a single tree that faithfully represents the ensemble. But it’s computationally expensive, and it might give you a tree that tries to fit the noise of the ensemble. After all, learning is a messy process.</p>

<p>You could also distill the ensemble: train a new tree not against the original training data but against the ensemble’s predictions. The student learns from the teacher. But even learning an optimal decision tree is NP-hard, so you fall back to greedy algorithms like CART. The process is lossy. You’re not sure what detail it misses. It captures the main essence, but you’ve traded some accuracy for interpretability without a clear guarantee of what you kept and what you lost.</p>

<p>So we’re kind of stuck. The ensemble is accurate but unreadable. Collapsing it exactly is intractable. Distilling it is lossy and hard to reason about.</p>

<p>Let’s hold on to this thought and take a detour.</p>

<h2 id="symbolic-regression">Symbolic regression</h2>

<p>Symbolic regression is a different approach to learning from data entirely. Instead of fitting parameters of a fixed model (like linear regression fitting coefficients), it searches over the space of mathematical expressions to discover the structure itself. The output isn’t a weight matrix or a tree. It’s an equation: $y = 2.5\,x^2 + 0.3\,x$, or $F = G\,m_1\,m_2 / r^2$.</p>

<p>Most symbolic regression methods use genetic programming. You generate a population of candidate expressions, evaluate them against the data, run tournaments where the best survive, crossover and mutate the survivors, and repeat. You are effectively trying to one-shot the learning problem: find the right expression structure and the right constants in a single search.</p>

<p>This works on small-ish datasets and is good at recovering the equation that describes the underlying data. Symbolic regression has been popular in the sciences because it promises testable hypotheses rather than a black box that produces just answers. It’s the process Kepler went through trying to find laws of planetary motion, except automated. In a <a href="https://www.dwarkeshpatel.com/p/terence-tao">podcast with Terrence Tao</a>, Dwarkesh Patel described Kepler’s process as being like a “high temperature LLM”. The analogy captures the trial-and-error aspect but misses the heart of it. Symbolic methods are greatly improved by a combination of reasoning and search. <a href="https://github.com/MilesCranmer/PySR">PySR</a>, the most popular tool for this, runs multi-population evolutionary search and recovers exact physical laws from noisy data in seconds.</p>

<p>There’s also recent work by Fabricio Olivetti de França on <a href="https://github.com/folivetti/eggp">eggp</a>, a symbolic regression system that uses e-graphs to prune the search space. We’re already a few digressions deep, but the short version is that e-graphs are a data structure that stores many equivalent forms of an expression simultaneously, and after applying algebraic rewrite rules, gives you back the simplest one. Think of it as a compiler optimization pass for mathematical expressions.</p>

<h2 id="the-convergence">The convergence</h2>

<p>So XGBoost has this property where an ensemble of trees is accurate but hard to collapse into something readable. And symbolic regression typically tries to one-shot learn a single equation from data. What if we combined the two approaches?</p>

<p>The idea is: use boosting to build an ensemble of small symbolic expressions (not trees, but tiny equations like <code class="language-plaintext highlighter-rouge">0.3*x^2</code> or <code class="language-plaintext highlighter-rouge">sin(x0)</code>), then collapse the ensemble algebraically into a single clean formula. Accept that learning is messy, but have techniques to consolidate it later.</p>

<p>This is similar to how studying works. You take a bunch of scattered notes during a lecture, full of redundancies and partial thoughts, and later consolidate them into a neater structure. Traditional symbolic regression tries to make the lecture notes and the final summary the same document. Boosting lets them be different. You scribble fast and clean up later.</p>

<p>It also reminds me of the wake-sleep algorithm from the <a href="https://arxiv.org/abs/2006.08381">DreamCoder</a> paper on program synthesis. DreamCoder alternates between a “wake” phase (solve problems using your current library) and a “sleep” phase (compress solutions into reusable abstractions). The wake phase is messy and exploratory. The sleep phase consolidates. Symbolic boosting has the same rhythm: grow the ensemble (wake), simplify it (sleep), grow again, simplify again.</p>

<h2 id="how-it-works">How it works</h2>

<p>Concretely, symbolic boosting works like this. Initialize the model as the mean of the target. The residuals are everything the mean doesn’t explain. Then, each round:</p>

<ol>
  <li>Generate 50 random expression trees, each with at most 7 nodes. The grammar includes <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">-</code>, <code class="language-plaintext highlighter-rouge">*</code>, and squaring.</li>
  <li>Fit each candidate’s learnable parameters against the current residuals using L-BFGS-B (30 iterations, 2 random restarts).</li>
  <li>Score each candidate by how much it reduces the residual variance.</li>
  <li>Pick the winner. Shrink it by a learning rate (default 0.1) and add it to the ensemble.</li>
  <li>Update residuals and go again.</li>
</ol>

<p>After 30 rounds, the ensemble might look like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.83*x*y + (-0.90)*x*y + 1.18*y*(x+y) - 1.01*(y-0.51)^2 - 0.42*y - 0.76
</code></pre></div></div>

<p>That’s the scribbled lecture notes. Redundant terms, partial cancellations, coefficients scattered everywhere. The true answer is <code class="language-plaintext highlighter-rouge">x*y</code>, buried under noise.</p>

<p>Now for the consolidation.</p>

<h2 id="simplification-polynomial-normal-form">Simplification: polynomial normal form</h2>

<p>If you ignore transcendental functions (<code class="language-plaintext highlighter-rouge">sin</code>, <code class="language-plaintext highlighter-rouge">cos</code>, <code class="language-plaintext highlighter-rouge">exp</code>, <code class="language-plaintext highlighter-rouge">log</code>), the boosting ensemble is a polynomial. And polynomials have a canonical form where identical monomials are merged automatically.</p>

<p>Take the messy ensemble above:</p>

\[-0.18\,y(x+y) + 1.18\,y(x+y) - 1.01(y-0.51)^2 - 0.42\,y - 0.76\]

<p>Expand every product and collect like terms:</p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>What happens</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Expand products</td>
      <td>$y(x+y) \to xy + y^2$</td>
    </tr>
    <tr>
      <td>Merge like terms</td>
      <td>$-0.18\,xy + 1.18\,xy \to 1.0\,xy$</td>
    </tr>
    <tr>
      <td>Cancel near-zero</td>
      <td>$y^2$ coefficient $\approx -0.01$, dropped</td>
    </tr>
    <tr>
      <td>Collect constants</td>
      <td>$-0.26 + (-0.76) \to -1.02$</td>
    </tr>
  </tbody>
</table>

<p>Result: $xy + 0.77\,y - 1.02$. Three terms instead of seventeen.</p>

<p>The implementation is around 25 lines of Python using SymPy:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">TRANSCENDENTALS</span> <span class="o">=</span> <span class="p">(</span><span class="n">sympy</span><span class="p">.</span><span class="n">sin</span><span class="p">,</span> <span class="n">sympy</span><span class="p">.</span><span class="n">cos</span><span class="p">,</span> <span class="n">sympy</span><span class="p">.</span><span class="n">exp</span><span class="p">,</span> <span class="n">sympy</span><span class="p">.</span><span class="n">log</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">poly_nf</span><span class="p">(</span><span class="n">expr</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">):</span>
    <span class="n">atom_map</span><span class="p">,</span> <span class="n">ctr</span> <span class="o">=</span> <span class="p">{},</span> <span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="k">def</span> <span class="nf">replace_trans</span><span class="p">(</span><span class="n">e</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">e</span><span class="p">.</span><span class="n">func</span> <span class="ow">in</span> <span class="n">TRANSCENDENTALS</span><span class="p">:</span>
            <span class="n">inner</span> <span class="o">=</span> <span class="n">poly_nf</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">threshold</span><span class="p">)</span>
            <span class="n">ne</span> <span class="o">=</span> <span class="n">e</span><span class="p">.</span><span class="n">func</span><span class="p">(</span><span class="n">inner</span><span class="p">);</span> <span class="n">k</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">ne</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">k</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">atom_map</span><span class="p">:</span>
                <span class="n">s</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">Symbol</span><span class="p">(</span><span class="sa">f</span><span class="s">"atom</span><span class="si">{</span><span class="n">ctr</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">);</span> <span class="n">ctr</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
                <span class="n">atom_map</span><span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">ne</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">atom_map</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="n">func</span><span class="p">(</span><span class="o">*</span><span class="p">[</span><span class="n">replace_trans</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">e</span><span class="p">.</span><span class="n">args</span><span class="p">])</span>
                <span class="k">if</span> <span class="n">e</span><span class="p">.</span><span class="n">args</span> <span class="k">else</span> <span class="n">e</span><span class="p">)</span>
    <span class="n">replaced</span> <span class="o">=</span> <span class="n">replace_trans</span><span class="p">(</span><span class="n">expr</span><span class="p">)</span>
    <span class="n">expanded</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">expand</span><span class="p">(</span><span class="n">replaced</span><span class="p">)</span>
    <span class="n">terms</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">Add</span><span class="p">.</span><span class="n">make_args</span><span class="p">(</span><span class="n">expanded</span><span class="p">)</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">terms</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">expr</span>
    <span class="n">kept</span> <span class="o">=</span> <span class="p">[</span><span class="n">t</span> <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">terms</span>
            <span class="k">if</span> <span class="nb">abs</span><span class="p">(</span><span class="nb">float</span><span class="p">(</span><span class="n">t</span><span class="p">.</span><span class="n">as_coeff_Mul</span><span class="p">()[</span><span class="mi">0</span><span class="p">]))</span> <span class="o">&gt;</span> <span class="n">threshold</span><span class="p">]</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="o">*</span><span class="n">kept</span><span class="p">)</span> <span class="k">if</span> <span class="n">kept</span> <span class="k">else</span> <span class="n">sympy</span><span class="p">.</span><span class="n">Float</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">orig</span><span class="p">)</span> <span class="ow">in</span> <span class="n">atom_map</span><span class="p">.</span><span class="n">items</span><span class="p">():</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="n">subs</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">orig</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">result</span>
</code></pre></div></div>

<p>First, replace every transcendental subexpression (<code class="language-plaintext highlighter-rouge">sin(...)</code>, <code class="language-plaintext highlighter-rouge">exp(...)</code>, etc.) with a fresh placeholder symbol. This draws a boundary: everything polynomial gets expanded and merged, everything transcendental stays opaque. Then <code class="language-plaintext highlighter-rouge">sympy.expand</code> distributes all products over sums. Like terms merge automatically. Drop anything with a near-zero coefficient.</p>

<p>The transcendental boundary is important. Without it, <code class="language-plaintext highlighter-rouge">sympy.expand</code> would try to expand through <code class="language-plaintext highlighter-rouge">sin(x + y)</code> and fail. With it, <code class="language-plaintext highlighter-rouge">sin(x + y)</code> becomes an atom that participates in polynomial arithmetic the same way a variable does. After simplification, the placeholders are substituted back.</p>

<p>The blowup guard (bail out if expansion produces more than 200 terms) prevents polynomial multiplication from cascading. Two 15-term polynomials multiplied together produce up to 225 monomials. Without the guard, nested products could blow up the representation.</p>

<h2 id="simplification-magnitude-pruning">Simplification: magnitude pruning</h2>

<p>Polynomial normal form is algebraic simplification. Magnitude pruning is statistical simplification.</p>

<p>After expansion, you might have a monomial like <code class="language-plaintext highlighter-rouge">0.003 * cos(y)</code> alongside a dominant <code class="language-plaintext highlighter-rouge">98.7 * x * y</code>. Magnitude pruning evaluates each monomial on the training data, measures its peak absolute contribution, and drops anything contributing less than 3% of the dominant term. The <code class="language-plaintext highlighter-rouge">cos(y)</code> correction is noise relative to the signal. Drop it.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">mag_prune</span><span class="p">(</span><span class="n">expr</span><span class="p">,</span> <span class="n">X_data</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="mf">0.03</span><span class="p">):</span>
    <span class="n">expanded</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">expand</span><span class="p">(</span><span class="n">expr</span><span class="p">)</span>
    <span class="n">terms</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">sympy</span><span class="p">.</span><span class="n">Add</span><span class="p">.</span><span class="n">make_args</span><span class="p">(</span><span class="n">expanded</span><span class="p">))</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">terms</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">1</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">expr</span>
    <span class="n">fsyms</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">expr</span><span class="p">.</span><span class="n">free_symbols</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="nb">str</span><span class="p">)</span>
    <span class="n">peaks</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">terms</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">fn</span> <span class="o">=</span> <span class="n">sympy</span><span class="p">.</span><span class="n">lambdify</span><span class="p">(</span><span class="n">fsyms</span><span class="p">,</span> <span class="n">t</span><span class="p">,</span> <span class="n">modules</span><span class="o">=</span><span class="p">[</span><span class="s">"numpy"</span><span class="p">])</span>
            <span class="n">vals</span> <span class="o">=</span> <span class="n">fn</span><span class="p">(</span><span class="o">*</span><span class="p">[</span><span class="n">X_data</span><span class="p">[:,</span> <span class="nb">int</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">s</span><span class="p">)[</span><span class="mi">1</span><span class="p">:])]</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">fsyms</span><span class="p">])</span>
            <span class="n">peaks</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">nanmax</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="nb">abs</span><span class="p">(</span>
                <span class="n">np</span><span class="p">.</span><span class="n">nan_to_num</span><span class="p">(</span><span class="n">vals</span><span class="p">,</span> <span class="n">nan</span><span class="o">=</span><span class="mf">0.0</span><span class="p">)))))</span>
        <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
            <span class="n">peaks</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="nb">float</span><span class="p">(</span><span class="s">"inf"</span><span class="p">))</span>
    <span class="n">mx</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">peaks</span><span class="p">)</span> <span class="k">if</span> <span class="n">peaks</span> <span class="k">else</span> <span class="mf">1.0</span>
    <span class="k">if</span> <span class="n">mx</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">expr</span>
    <span class="n">kept</span> <span class="o">=</span> <span class="p">[</span><span class="n">t</span> <span class="k">for</span> <span class="n">t</span><span class="p">,</span> <span class="n">p</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">terms</span><span class="p">,</span> <span class="n">peaks</span><span class="p">)</span> <span class="k">if</span> <span class="n">p</span> <span class="o">/</span> <span class="n">mx</span> <span class="o">&gt;=</span> <span class="n">threshold</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">sympy</span><span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="o">*</span><span class="n">kept</span><span class="p">)</span> <span class="k">if</span> <span class="n">kept</span> <span class="k">else</span> <span class="n">expr</span>
</code></pre></div></div>

<h2 id="simplification-e-graph-cleanup">Simplification: e-graph cleanup</h2>

<p>Polynomial normal form and magnitude pruning handle polynomial redundancy and statistical insignificance. But there’s a third kind of redundancy: algebraic identities involving transcendental functions.</p>

<p>If the ensemble contains <code class="language-plaintext highlighter-rouge">log(exp(x))</code>, polynomial normal form treats it as an opaque atom and leaves it alone. It doesn’t know that <code class="language-plaintext highlighter-rouge">log(exp(x)) = x</code>. Similarly, <code class="language-plaintext highlighter-rouge">sqrt(x)^2 = x</code>, <code class="language-plaintext highlighter-rouge">x + 0 = x</code>, and <code class="language-plaintext highlighter-rouge">x * 1 = x</code> are identities that polynomial algebra can’t simplify.</p>

<p>This is where e-graphs come back. The notebook uses <a href="https://github.com/egraphs-good/egglog">egglog</a> to apply a conservative set of rewrite rules:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">erules</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">rewrite</span><span class="p">(</span><span class="n">a</span> <span class="o">+</span> <span class="n">MathE</span><span class="p">(</span><span class="mf">0.0</span><span class="p">)).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>        <span class="c1"># x + 0 → x
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">a</span> <span class="o">*</span> <span class="n">MathE</span><span class="p">(</span><span class="mf">1.0</span><span class="p">)).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>         <span class="c1"># x * 1 → x
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">a</span> <span class="o">*</span> <span class="n">MathE</span><span class="p">(</span><span class="mf">0.0</span><span class="p">)).</span><span class="n">to</span><span class="p">(</span><span class="n">MathE</span><span class="p">(</span><span class="mf">0.0</span><span class="p">)),</span><span class="c1"># x * 0 → 0
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="o">-</span><span class="p">(</span><span class="o">-</span><span class="n">a</span><span class="p">)).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>                  <span class="c1"># double negation
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">Log</span><span class="p">(</span><span class="n">Exp</span><span class="p">(</span><span class="n">a</span><span class="p">))).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>            <span class="c1"># log(exp(x)) → x
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">Exp</span><span class="p">(</span><span class="n">Log</span><span class="p">(</span><span class="n">a</span><span class="p">))).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>            <span class="c1"># exp(log(x)) → x
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">Sqrt</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="o">**</span> <span class="n">MathE</span><span class="p">(</span><span class="mf">2.0</span><span class="p">)).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span><span class="p">),</span>  <span class="c1"># sqrt(x)^2 → x
</span>    <span class="n">rewrite</span><span class="p">(</span><span class="n">a</span> <span class="o">*</span> <span class="n">a</span><span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">a</span> <span class="o">**</span> <span class="n">MathE</span><span class="p">(</span><span class="mf">2.0</span><span class="p">)),</span>    <span class="c1"># x*x → x^2
</span><span class="p">]</span>
</code></pre></div></div>

<p>Insert the expression, run saturation for 30 iterations, extract the smallest equivalent.</p>

<p>The rules are deliberately conservative. Distributivity (<code class="language-plaintext highlighter-rouge">a*(b+c) = a*b + a*c</code>) is not included because it causes e-graphs to explode. Polynomial normal form already handles distributivity structurally, so the e-graph only needs to handle what polynomials can’t.</p>

<h2 id="the-sawtooth">The sawtooth</h2>

<p>The boosting loop doesn’t simplify every round. It simplifies every 5 rounds. The ensemble grows messy for 5 rounds, then collapses when the simplification pipeline fires. Then it grows messy again, then collapses again.</p>

<p>This creates a sawtooth pattern in expression complexity. The notebook visualizes this: the top plot shows complexity spiking up and crashing down at each collapse event, the bottom plot shows R-squared climbing steadily through the collapses. The collapses don’t hurt accuracy because simplification doesn’t change semantics. Merging <code class="language-plaintext highlighter-rouge">0.83*x*y + (-0.90)*x*y</code> into <code class="language-plaintext highlighter-rouge">-0.07*x*y</code> produces the exact same predictions on every input. The expression gets smaller, not different.</p>

<p>The rhythm is: grow, simplify, grow, simplify. Scribble, consolidate, scribble, consolidate.</p>

<h2 id="where-it-works-and-where-it-doesnt">Where it works and where it doesn’t</h2>

<p>The notebook includes four target functions to try boosting against.</p>

<p><strong><code class="language-plaintext highlighter-rouge">2.5*x^2 + 0.3*x</code></strong> (simple quadratic). A polynomial. Boosting nails it. The ensemble discovers <code class="language-plaintext highlighter-rouge">x^2</code> and <code class="language-plaintext highlighter-rouge">x</code> terms early, and polynomial normal form collapses them into the exact answer.</p>

<p><strong><code class="language-plaintext highlighter-rouge">sin(x1) + cos(x2)</code></strong> (trigonometric). Also additive. Each trig function is a plausible weak learner on its own. Boosting finds the first term, the residuals look like <code class="language-plaintext highlighter-rouge">cos(x2)</code>, and it finds the second.</p>

<p><strong><code class="language-plaintext highlighter-rouge">3.0*x1^2 + 0.5/x2</code></strong> (physics-inspired). The polynomial part is easy. The <code class="language-plaintext highlighter-rouge">0.5/x2</code> is a single division. If the random generator produces something like <code class="language-plaintext highlighter-rouge">param0 / x1</code>, parameter fitting finds the right coefficient.</p>

<p><strong><code class="language-plaintext highlighter-rouge">x1 * exp(-x2^2) + 2.3</code></strong> (complex). This is the hard one. <code class="language-plaintext highlighter-rouge">exp(-x2^2)</code> is a deeply nested composition. No single 7-node weak learner can express <code class="language-plaintext highlighter-rouge">x1 * exp(-x2^2)</code>. The ensemble will approximate it with polynomial corrections, but it’s unlikely to recover the exact structure.</p>

<p>This is the fundamental limitation. Some mathematical structures are not additive. <code class="language-plaintext highlighter-rouge">exp(-x^2)</code> has to be built as a single composite expression, not assembled from a sum of corrections. Boosting will converge to a good numerical approximation, but the formula it produces will be a polynomial approximation of the true equation, not the equation itself.</p>

<p>PySR’s evolutionary approach doesn’t have this bias. It searches over full expression trees, so it can discover <code class="language-plaintext highlighter-rouge">exp(-x^2)</code> as a single unit. The tradeoff: PySR needs more time and is more stochastic. Boosting is faster and more predictable, but structurally limited to additive decompositions.</p>

<p>The sweet spot is problems where the underlying equation is a sum of a few terms, each of moderate complexity. Coulomb’s law (<code class="language-plaintext highlighter-rouge">q1*q2 / r^2</code>), kinetic energy (<code class="language-plaintext highlighter-rouge">0.5*m*v^2</code>), spring force (<code class="language-plaintext highlighter-rouge">k*x</code>). Additive or multiplicative structure that the polynomial representation can collapse.</p>

<h2 id="try-it-yourself">Try it yourself</h2>

<p>The three tools that make this possible: <a href="https://github.com/MilesCranmer/PySR">PySR</a> for evolutionary symbolic regression, <a href="https://www.sympy.org/">SymPy</a> for symbolic algebra and the polynomial normal form, and <a href="https://github.com/egraphs-good/egglog">egglog</a> for e-graph equality saturation.</p>

<p>Marimo notebooks are great for exploring this sort of approach since they make visualizing the outputs easy. We can leverage human intelligence to further audit and simplify models.</p>

<h2 id="why-this-is-exciting">Why this is exciting</h2>

<p>Symbolic regression has always been hamstrung by scale. Finding ways to make training loops smaller and more efficient removes an important hurdle to its adoption. Finding different ways to create symbolic models that search for or approximate exact equations might be a chapter in the AI story where we actually think about how we can collaborate with machines to discover new things.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This post is the convergence of two ideas that have been floating in my head for about a year. Can we learn messy stochastic models and use algorithmic/algebraic tools to rein in model complexity to make models interpretable?]]></summary></entry><entry><title type="html">Type-level programming is still programming</title><link href="mchav.github.io/type-level-programming-is-still-programming/" rel="alternate" type="text/html" title="Type-level programming is still programming" /><published>2026-04-04T00:00:00+00:00</published><updated>2026-04-04T00:00:00+00:00</updated><id>mchav.github.io/type-level-programming-is-still-programming</id><content type="html" xml:base="mchav.github.io/type-level-programming-is-still-programming/"><![CDATA[<p>I was showing a friend the typed dataframe API. The whole pitch was: look, you derive a schema from your data, and then the compiler catches column name typos, type mismatches, all the stuff that would otherwise blow up at runtime. I had a nice demo ready using the <a href="https://www.kaggle.com/datasets/mlg-ulb/creditcardfraud">Kaggle credit card fraud dataset</a> (about 284,000 rows, 31 columns).</p>

<p>I loaded it up in GHCi and ran the untyped version first:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">df</span> <span class="o">&lt;-</span> <span class="kt">D</span><span class="o">.</span><span class="n">readCsv</span> <span class="s">"./data/creditcard.csv"</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="o">:</span><span class="n">set</span> <span class="o">+</span><span class="n">s</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="o">:</span><span class="n">declareColumns</span> <span class="n">df</span>
<span class="n">time</span> <span class="o">::</span> <span class="kt">Expr</span> <span class="kt">Double</span>
<span class="n">v1</span> <span class="o">::</span> <span class="kt">Expr</span> <span class="kt">Double</span>
<span class="n">v2</span> <span class="o">::</span> <span class="kt">Expr</span> <span class="kt">Double</span>
<span class="o">...</span>
<span class="n">_class_</span> <span class="o">::</span> <span class="kt">Expr</span> <span class="kt">Int</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">df</span> <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">_class_</span><span class="p">]</span>
         <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"rand"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">mean</span> <span class="n">v1</span> <span class="o">-</span> <span class="kt">F</span><span class="o">.</span><span class="n">mean</span> <span class="n">v2</span><span class="p">]</span>
         <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">select</span> <span class="p">[</span><span class="s">"rand"</span><span class="p">]</span>
<span class="c1">---------------------</span>
        <span class="n">rand</span>
<span class="c1">---------------------</span>
       <span class="kt">Double</span>
<span class="c1">---------------------</span>
<span class="o">-</span><span class="mf">8.395726543461365</span>
<span class="mf">1.4528594901372989e-2</span>

<span class="p">(</span><span class="mf">0.92</span> <span class="n">secs</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span><span class="mi">287</span><span class="p">,</span><span class="mi">695</span><span class="p">,</span><span class="mi">984</span> <span class="n">bytes</span><span class="p">)</span>
</code></pre></div></div>

<p>But suppose you had a runtime error in the last part of the pipeline?</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">df</span> <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">groupBy</span> <span class="p">[</span><span class="kt">F</span><span class="o">.</span><span class="n">name</span> <span class="n">_class_</span><span class="p">]</span>
         <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">[</span><span class="s">"rand"</span> <span class="o">.=</span> <span class="kt">F</span><span class="o">.</span><span class="n">mean</span> <span class="n">v1</span> <span class="o">-</span> <span class="kt">F</span><span class="o">.</span><span class="n">mean</span> <span class="n">v2</span><span class="p">]</span>
         <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">select</span> <span class="p">[</span><span class="s">"ran"</span><span class="p">]</span>

<span class="o">***</span> <span class="kt">Exception</span><span class="o">:</span> 

<span class="p">[</span><span class="kt">ERROR</span><span class="p">]</span> <span class="kt">Column</span> <span class="n">not</span> <span class="n">found</span><span class="o">:</span> <span class="n">ran</span> <span class="n">for</span> <span class="n">operation</span> <span class="n">select</span>
	<span class="kt">Did</span> <span class="n">you</span> <span class="n">mean</span> <span class="n">rand</span><span class="o">?</span>
</code></pre></div></div>

<p>Now you’ve waited the duration of the entire execution of the pipeline (allocations and all) to discover that there is a misnamed column. That’s pretty wasteful.</p>

<p>So I ported the example to the typed dataframe and API and ran it. First the misnamed version:</p>

<p>The example did show that when you misname the column it fails at compile time.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dataframe</span><span class="o">&gt;</span> <span class="o">:</span><span class="n">set</span> <span class="o">-</span><span class="kt">XDataKinds</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame.Typed</span> <span class="k">as</span> <span class="n">DT</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="kr">_</span> <span class="o">=</span> <span class="nb">()</span><span class="p">;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">deriveSchema</span> <span class="s">"CreditCard"</span> <span class="n">df</span><span class="o">.</span> <span class="c1">-- Template Haskell trick for GHCi</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">tdf</span> <span class="o">=</span> <span class="n">either</span> <span class="p">(</span><span class="n">error</span> <span class="o">.</span> <span class="n">show</span><span class="p">)</span> <span class="n">id</span> <span class="p">(</span><span class="kt">DataFrame</span><span class="o">.</span><span class="kt">Typed</span><span class="o">.</span><span class="n">freezeWithError</span> <span class="o">@</span><span class="kt">CreditCard</span> <span class="n">df</span><span class="p">)</span>
<span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">tdf</span> <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"Class"</span><span class="p">]</span>
               <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">agg</span> <span class="o">@</span><span class="s">"rand"</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V1"</span><span class="p">)</span> <span class="o">-</span> <span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V2"</span><span class="p">))</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggNil</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">select</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"ran"</span><span class="p">]</span>
<span class="o">&lt;</span><span class="n">interactive</span><span class="o">&gt;:</span><span class="mi">39</span><span class="o">:</span><span class="mi">127</span><span class="o">:</span> <span class="n">error</span><span class="o">:</span> <span class="p">[</span><span class="kt">GHC</span><span class="o">-</span><span class="mi">64725</span><span class="p">]</span>
    <span class="err">•</span> <span class="kt">Column</span> <span class="n">'ran'</span> <span class="n">not</span> <span class="n">found</span> <span class="kr">in</span> <span class="n">schema</span>
    <span class="err">•</span> <span class="kt">In</span> <span class="n">the</span> <span class="n">second</span> <span class="n">argument</span> <span class="kr">of</span> <span class="err">‘</span><span class="p">(</span><span class="o">|&gt;</span><span class="p">)</span><span class="err">’</span><span class="p">,</span> <span class="n">namely</span> <span class="err">‘</span><span class="kt">DT</span><span class="o">.</span><span class="n">select</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"ran"</span><span class="p">]</span><span class="err">’</span>
      <span class="kt">In</span> <span class="n">the</span> <span class="n">expression</span><span class="o">:</span>
        <span class="n">tdf</span> <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"Class"</span><span class="p">]</span>
          <span class="o">|&gt;</span>
            <span class="kt">DT</span><span class="o">.</span><span class="n">aggregate</span>
              <span class="p">(</span><span class="n">agg</span>
                 <span class="o">@</span><span class="s">"rand"</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V1"</span><span class="p">)</span> <span class="o">-</span> <span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V2"</span><span class="p">))</span> <span class="n">aggNil</span><span class="p">)</span>
          <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">select</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"ran"</span><span class="p">]</span>
<span class="p">(</span><span class="mf">0.00</span> <span class="n">secs</span><span class="p">,)</span>
</code></pre></div></div>

<p>And then the happy path:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dataframe</span><span class="o">&gt;</span> <span class="n">tdf</span> <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"Class"</span><span class="p">]</span>
               <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">agg</span> <span class="o">@</span><span class="s">"rand"</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V1"</span><span class="p">)</span> <span class="o">-</span> <span class="kt">DT</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">DT</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"V2"</span><span class="p">))</span> <span class="kt">DT</span><span class="o">.</span><span class="n">aggNil</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">DT</span><span class="o">.</span><span class="n">select</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"rand"</span><span class="p">]</span>
</code></pre></div></div>

<p>Nothing. The cursor just sat there. I waited a minute. Two minutes. Still nothing. My friend was polite about it and started conjecturing about what could be wrong. But the happy path just didn’t finish and eventually OOM’d.</p>

<h2 id="chasing-ghosts">Chasing ghosts</h2>

<p>I spent a while convinced the problem was runtime. Both APIs generate the same expression tree under the hood. Both call the same compiled aggregation function. The typed wrappers are just newtypes. So I went looking for specialization failures, thinking GHCi wasn’t generating efficient code for the polymorphic hot loops.</p>

<p>I added <code class="language-plaintext highlighter-rouge">SPECIALIZE</code> pragmas to <code class="language-plaintext highlighter-rouge">foldLinearGroups</code>, <code class="language-plaintext highlighter-rouge">mapColumn</code>, <code class="language-plaintext highlighter-rouge">zipWithColumns</code>, and every aggregation function in <code class="language-plaintext highlighter-rouge">Functions.hs</code>. I made the typed combinators delegate to the untyped compiled versions instead of defining their own closures. I specialized for <code class="language-plaintext highlighter-rouge">Double</code>, <code class="language-plaintext highlighter-rouge">Float</code>, <code class="language-plaintext highlighter-rouge">Int</code>, <code class="language-plaintext highlighter-rouge">Int8</code>, <code class="language-plaintext highlighter-rouge">Int16</code>, <code class="language-plaintext highlighter-rouge">Int32</code>, <code class="language-plaintext highlighter-rouge">Int64</code>. The whole works.</p>

<p>No change. Still hung.</p>

<p>At this point I wrote both pipelines into a compiled executable with <code class="language-plaintext highlighter-rouge">-O2</code> and profiling enabled. Both finished in 13 milliseconds. Identical. The runtime code was not the problem. Whatever was happening was specific to GHCi.</p>

<p>Then I did the thing I should have done from the start. I put <code class="language-plaintext highlighter-rouge">Debug.Trace</code> calls at every stage of the pipeline:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregate</span> <span class="n">tagg</span> <span class="p">(</span><span class="kt">TGD</span> <span class="n">gdf</span><span class="p">)</span> <span class="o">=</span>
    <span class="n">trace</span> <span class="s">"[typed.aggregate] START"</span> <span class="o">$</span>
    <span class="n">unsafeFreeze</span> <span class="p">(</span><span class="kt">DA</span><span class="o">.</span><span class="n">aggregate</span> <span class="p">(</span><span class="n">taggToNamedExprs</span> <span class="n">tagg</span><span class="p">)</span> <span class="n">gdf</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregate</span> <span class="n">aggs</span> <span class="n">gdf</span><span class="o">@</span><span class="p">(</span><span class="kt">Grouped</span> <span class="n">df</span> <span class="o">...</span><span class="p">)</span> <span class="o">=</span>
    <span class="n">trace</span> <span class="s">"[aggregate] START"</span> <span class="o">$</span>
    <span class="o">...</span>
</code></pre></div></div>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">eval</span> <span class="n">ctx</span> <span class="n">expr</span><span class="o">@</span><span class="p">(</span><span class="kt">Binary</span> <span class="o">...</span><span class="p">)</span> <span class="o">=</span>
    <span class="n">trace</span> <span class="s">"[eval] Binary"</span> <span class="o">$</span>
    <span class="o">...</span>
</code></pre></div></div>

<p>None of them fired. The program never reached runtime.</p>

<p>I tried a few more things after that. I thought maybe it was laziness, that evaluation was being deferred and the traces weren’t forced. I added bang patterns, <code class="language-plaintext highlighter-rouge">seq</code> calls, explicit <code class="language-plaintext highlighter-rouge">evaluate</code> in IO. Nothing changed. The traces weren’t printing because the code wasn’t running.</p>

<p>That’s when I understood: the hang was happening during GHCi’s type-checking of the expression at the prompt.</p>

<h2 id="the-confirmation">The confirmation</h2>

<p>To be sure, I ran the same typed pipeline against the housing dataset. 10 columns instead of 31.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[typed.aggregate] START
[typed.aggregate] exprs: [("rand",(sub (mean (col @Double "median_income"))
                                       (mean (col @Double "housing_median_age"))))]
[aggregate] START
[eval] Binary
[eval] MergeAgg FAST PATH col="median_income"
[eval] MergeAgg FAST PATH col="housing_median_age"
[eval] Binary: liftValue2
[aggregate] expr DONE
(0.55 secs, 410,398,240 bytes)
</code></pre></div></div>

<p>Everything worked. All the traces fired. Fast path, correct result. So the runtime code was fine. The problem scaled with the number of columns in the schema. 10 columns: instant. 31 columns: heat death of the universe.</p>

<h2 id="the-actual-bug">The actual bug</h2>

<p>The typed API uses type families to compute result schemas. When you call <code class="language-plaintext highlighter-rouge">groupBy @'["Class"]</code> on a 31-column schema, the type checker needs to figure out which columns are grouping keys. That computation was done by a type family called <code class="language-plaintext highlighter-rouge">GroupKeyColumns</code> which itself uses a type-level <code class="language-plaintext highlighter-rouge">If</code> statement to check for column existence:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="n">family</span> <span class="kt">GroupKeyColumns</span> <span class="p">(</span><span class="n">keys</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Symbol</span><span class="p">])</span> <span class="p">(</span><span class="n">cols</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">'</span><span class="kt">[]</span> <span class="o">=</span> <span class="n">'</span><span class="kt">[]</span>
    <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="p">(</span><span class="kt">Column</span> <span class="n">n</span> <span class="n">a</span> <span class="n">'</span><span class="o">:</span> <span class="n">rest</span><span class="p">)</span> <span class="o">=</span>
        <span class="kt">If</span>
            <span class="p">(</span><span class="kt">IsElem</span> <span class="n">n</span> <span class="n">keys</span><span class="p">)</span>
            <span class="p">(</span><span class="kt">Column</span> <span class="n">n</span> <span class="n">a</span> <span class="n">'</span><span class="o">:</span> <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">rest</span><span class="p">)</span>
            <span class="p">(</span><span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">rest</span><span class="p">)</span>
</code></pre></div></div>

<p>It’s easy to think of type-level programming as some kind of compiler magic that either works or doesn’t. But type families are a sort of programming language that must be reasoned about with the same tools we use to reason about regular programs. They take arguments, do recursion, and return results.</p>

<p>So let’s trace through what <code class="language-plaintext highlighter-rouge">GroupKeyColumns '["Class"]</code> does on a 3-column schema, step by step. This is the <code class="language-plaintext highlighter-rouge">If</code> version:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="n">family</span> <span class="kt">If</span> <span class="p">(</span><span class="n">cond</span> <span class="o">::</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">(</span><span class="n">t</span> <span class="o">::</span> <span class="n">k</span><span class="p">)</span> <span class="p">(</span><span class="n">f</span> <span class="o">::</span> <span class="n">k</span><span class="p">)</span> <span class="o">::</span> <span class="n">k</span> <span class="kr">where</span>
    <span class="kt">If</span> <span class="kt">'True</span>  <span class="n">t</span> <span class="n">f</span> <span class="o">=</span> <span class="n">t</span>
    <span class="kt">If</span> <span class="kt">'False</span> <span class="n">t</span> <span class="n">f</span> <span class="o">=</span> <span class="n">f</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GroupKeyColumns '["Class"] '[Column "V1" Double, Column "V2" Double, Column "Class" Int]

-- Step 1: n = "V1". IsElem "V1" '["Class"] = 'False.
-- GHC needs to evaluate If 'False &lt;then&gt; &lt;else&gt;.
-- But first it normalizes both arguments:
--   then = Column "V1" Double ': GroupKeyColumns '["Class"] '[Column "V2" Double, Column "Class" Int]
--   else = GroupKeyColumns '["Class"] '[Column "V2" Double, Column "Class" Int]
-- Both branches trigger the recursion. Two copies of the same work.

-- Step 2 (from the "then" branch): n = "V2". Same story. Two more copies.
-- Step 2 (from the "else" branch): n = "V2". Same story. Two more copies.
-- We now have 4 pending evaluations of GroupKeyColumns on '[Column "Class" Int].

-- Step 3: each of those 4 evaluates n = "Class". IsElem = 'True.
-- Each produces two more recursive calls on '[].
-- 8 evaluations of GroupKeyColumns '["Class"] '[] = '[].
</code></pre></div></div>

<p>Three columns, eight leaf evaluations. The tree of work doubles at every level. For n columns, GHC does 2^n reductions. Surprisingly, type-level if statement aren’t lazy as I think I expected them to be.</p>

<p>With 10 columns (the housing dataset I tested against), that’s 1,024 reductions. Imperceptible. With 31 columns (the Kaggle credit card dataset I demoed with), that’s 2,147,483,648 reductions. That’s why it hung.</p>

<p>The same analysis applies to any recursive type family that uses <code class="language-plaintext highlighter-rouge">If</code> with recursive calls in both branches. It’s the type-level equivalent of writing a naive Fibonacci function without memoization. The recursion tree fans out because GHC doesn’t share work between the two <code class="language-plaintext highlighter-rouge">If</code> arguments.</p>

<h2 id="the-fix">The fix</h2>

<p>I replaced <code class="language-plaintext highlighter-rouge">If</code> with a helper type family that pattern-matches directly on the boolean:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="n">family</span> <span class="kt">GroupKeyColumns</span> <span class="p">(</span><span class="n">keys</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Symbol</span><span class="p">])</span> <span class="p">(</span><span class="n">cols</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">'</span><span class="kt">[]</span> <span class="o">=</span> <span class="n">'</span><span class="kt">[]</span>
    <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="p">(</span><span class="kt">Column</span> <span class="n">n</span> <span class="n">a</span> <span class="n">'</span><span class="o">:</span> <span class="n">rest</span><span class="p">)</span> <span class="o">=</span>
        <span class="kt">GroupKeyColumnsHelper</span> <span class="p">(</span><span class="kt">IsElem</span> <span class="n">n</span> <span class="n">keys</span><span class="p">)</span> <span class="n">n</span> <span class="n">a</span> <span class="n">keys</span> <span class="n">rest</span>

<span class="kr">type</span> <span class="n">family</span> <span class="kt">GroupKeyColumnsHelper</span> <span class="p">(</span><span class="n">found</span> <span class="o">::</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">(</span><span class="n">n</span> <span class="o">::</span> <span class="kt">Symbol</span><span class="p">)</span> <span class="p">(</span><span class="n">a</span> <span class="o">::</span> <span class="kt">Type</span><span class="p">)</span>
    <span class="p">(</span><span class="n">keys</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Symbol</span><span class="p">])</span> <span class="p">(</span><span class="n">rest</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="kt">Type</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">GroupKeyColumnsHelper</span> <span class="kt">'True</span>  <span class="n">n</span> <span class="n">a</span> <span class="n">keys</span> <span class="n">rest</span> <span class="o">=</span>
        <span class="kt">Column</span> <span class="n">n</span> <span class="n">a</span> <span class="n">'</span><span class="o">:</span> <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">rest</span>
    <span class="kt">GroupKeyColumnsHelper</span> <span class="kt">'False</span> <span class="n">n</span> <span class="n">a</span> <span class="n">keys</span> <span class="n">rest</span> <span class="o">=</span>
        <span class="kt">GroupKeyColumns</span> <span class="n">keys</span> <span class="n">rest</span>
</code></pre></div></div>

<p>Now only the matching branch is ever constructed. The recursion is linear.</p>

<p>After the change the typed and untyped version run at roughly the same speed.</p>

<h2 id="why-the-helper-version-works">Why the helper version works</h2>

<p>Let’s trace the helper version on the same 3-column schema:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GroupKeyColumns '["Class"] '[Column "V1" Double, Column "V2" Double, Column "Class" Int]

-- Step 1: n = "V1". IsElem "V1" '["Class"] = 'False.
-- GHC evaluates: GroupKeyColumnsHelper 'False "V1" Double '["Class"] '[Column "V2" Double, Column "Class" Int]
-- Matches the 'False equation. Result: GroupKeyColumns '["Class"] '[Column "V2" Double, Column "Class" Int]
-- Only one recursive call. The 'True branch was never constructed.

-- Step 2: n = "V2". IsElem "V2" '["Class"] = 'False.
-- Same thing. One recursive call.

-- Step 3: n = "Class". IsElem "Class" '["Class"] = 'True.
-- Matches the 'True equation. Result: Column "Class" Int ': GroupKeyColumns '["Class"] '[]
-- One recursive call.

-- Step 4: GroupKeyColumns '["Class"] '[] = '[]
</code></pre></div></div>

<p>Four steps total. Linear. Each step does constant work because only the matching equation is ever expanded. The other equation doesn’t exist in the reduction trace at all.</p>

<p>This is a known footgun in type-level Haskell. But <code class="language-plaintext highlighter-rouge">If</code> is the natural thing to reach for when you want a conditional, and it works fine on small inputs. I tested everything against the 10-column housing dataset and it was fast. I didn’t think to test against 31 columns until I was demoing it live.</p>

<h2 id="the-rule-of-thumb">The rule of thumb</h2>

<p>If you have a recursive type family and you’re using <code class="language-plaintext highlighter-rouge">If</code> to select between branches, you have an exponential blowup waiting for a wide enough input. My fix was to introduce a helper type family with two equations and let GHC’s pattern matching do the branching instead of <code class="language-plaintext highlighter-rouge">If</code>.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Exponential: both branches are always reduced</span>
<span class="kr">type</span> <span class="n">family</span> <span class="kt">Foo</span> <span class="p">(</span><span class="n">xs</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">Foo</span> <span class="n">'</span><span class="kt">[]</span> <span class="o">=</span> <span class="n">'</span><span class="kt">[]</span>
    <span class="kt">Foo</span> <span class="p">(</span><span class="n">x</span> <span class="n">'</span><span class="o">:</span> <span class="n">xs</span><span class="p">)</span> <span class="o">=</span> <span class="kt">If</span> <span class="p">(</span><span class="kt">SomePredicate</span> <span class="n">x</span><span class="p">)</span> <span class="p">(</span><span class="n">x</span> <span class="n">'</span><span class="o">:</span> <span class="kt">Foo</span> <span class="n">xs</span><span class="p">)</span> <span class="p">(</span><span class="kt">Foo</span> <span class="n">xs</span><span class="p">)</span>

<span class="c1">-- Linear: only the matching branch is constructed</span>
<span class="kr">type</span> <span class="n">family</span> <span class="kt">Foo</span> <span class="p">(</span><span class="n">xs</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">Foo</span> <span class="n">'</span><span class="kt">[]</span> <span class="o">=</span> <span class="n">'</span><span class="kt">[]</span>
    <span class="kt">Foo</span> <span class="p">(</span><span class="n">x</span> <span class="n">'</span><span class="o">:</span> <span class="n">xs</span><span class="p">)</span> <span class="o">=</span> <span class="kt">FooHelper</span> <span class="p">(</span><span class="kt">SomePredicate</span> <span class="n">x</span><span class="p">)</span> <span class="n">x</span> <span class="n">xs</span>

<span class="kr">type</span> <span class="n">family</span> <span class="kt">FooHelper</span> <span class="p">(</span><span class="n">b</span> <span class="o">::</span> <span class="kt">Bool</span><span class="p">)</span> <span class="p">(</span><span class="n">x</span> <span class="o">::</span> <span class="n">k</span><span class="p">)</span> <span class="p">(</span><span class="n">xs</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">])</span> <span class="o">::</span> <span class="p">[</span><span class="n">k</span><span class="p">]</span> <span class="kr">where</span>
    <span class="kt">FooHelper</span> <span class="kt">'True</span>  <span class="n">x</span> <span class="n">xs</span> <span class="o">=</span> <span class="n">x</span> <span class="n">'</span><span class="o">:</span> <span class="kt">Foo</span> <span class="n">xs</span>
    <span class="kt">FooHelper</span> <span class="kt">'False</span> <span class="n">x</span> <span class="n">xs</span> <span class="o">=</span> <span class="kt">Foo</span> <span class="n">xs</span>
</code></pre></div></div>

<p>It’s more verbose but it’s O(n) instead of O(2^n). For 10 columns you won’t notice. For 31 columns you’ll be sitting there wondering why your program stopped responding while your friend (who is convinced that Haskell is an impractical academic fascination) watches.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I was showing a friend the typed dataframe API. The whole pitch was: look, you derive a schema from your data, and then the compiler catches column name typos, type mismatches, all the stuff that would otherwise blow up at runtime. I had a nice demo ready using the Kaggle credit card fraud dataset (about 284,000 rows, 31 columns).]]></summary></entry><entry><title type="html">What Category Theory Teaches Us About DataFrames</title><link href="mchav.github.io/what-category-theory-teaches-us-about-dataframes/" rel="alternate" type="text/html" title="What Category Theory Teaches Us About DataFrames" /><published>2026-03-28T00:00:00+00:00</published><updated>2026-03-28T00:00:00+00:00</updated><id>mchav.github.io/what-category-theory-teaches-us-about-dataframes</id><content type="html" xml:base="mchav.github.io/what-category-theory-teaches-us-about-dataframes/"><![CDATA[<p>Every dataframe library ships with hundreds of operations. pandas alone has over 200 methods on a DataFrame. Is <code class="language-plaintext highlighter-rouge">pivot</code> different from <code class="language-plaintext highlighter-rouge">melt</code>? Is <code class="language-plaintext highlighter-rouge">apply</code> different from <code class="language-plaintext highlighter-rouge">map</code>? What about <code class="language-plaintext highlighter-rouge">transform</code>, <code class="language-plaintext highlighter-rouge">agg</code>, <code class="language-plaintext highlighter-rouge">applymap</code>, <code class="language-plaintext highlighter-rouge">pipe</code>? Some of these seem like the same operation wearing different hats. Others seem genuinely distinct. Without a framework for telling them apart, you end up memorizing APIs instead of understanding structure.</p>

<p>I ran into this question while building my own dataframe library. I needed to decide which operations were truly fundamental and which were just surface-level variations. That search led me to Petersohn et al.’s <em>Towards Scalable Dataframe Systems</em>. They’d built Modin, a drop-in replacement for pandas, and needed to understand the actual structure underneath the API. They analyzed 1 million Jupyter notebooks, cataloged how people use pandas, and proposed a <em>dataframe algebra</em>: a formal set of about 15 operators that can express what all 200+ pandas operations do.</p>

<p>That algebra was a huge compression, but I kept wondering whether there was a level below it. Whether there was a smaller set of truly primitive operations that the 15 are built from. If those exist, they would be a real foundation: operations small enough to be obviously correct, expressive enough to build everything else.</p>

<h2 id="petersohns-dataframe-algebra">Petersohn’s dataframe algebra</h2>

<p>It’s worth spending some time on what Petersohn et al. actually did, because it frames everything that follows.</p>

<p>They started by defining what a dataframe is. Surprisingly, nobody had done this formally before. Their Definition 4.1 says a dataframe is a tuple <em>(A, R, C, D)</em>: an array of data <em>A</em>, row labels <em>R</em>, column labels <em>C</em>, and a vector of column domains <em>D</em>. This is more precise than “a table” because it captures things that make dataframes different from relational tables. Rows and columns are both ordered, both labeled, and treated symmetrically. You can transpose a dataframe. You can promote data values into column labels. These aren’t things you can do with a SQL table.</p>

<p>Then they identified the operators. Here’s their Table 1, condensed:</p>

<table>
  <thead>
    <tr>
      <th>Operator</th>
      <th>Origin</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SELECTION</td>
      <td>Relational</td>
      <td>Eliminate rows</td>
    </tr>
    <tr>
      <td>PROJECTION</td>
      <td>Relational</td>
      <td>Eliminate columns</td>
    </tr>
    <tr>
      <td>UNION</td>
      <td>Relational</td>
      <td>Combine two dataframes vertically</td>
    </tr>
    <tr>
      <td>DIFFERENCE</td>
      <td>Relational</td>
      <td>Rows in one but not the other</td>
    </tr>
    <tr>
      <td>CROSS PRODUCT / JOIN</td>
      <td>Relational</td>
      <td>Combine two dataframes by key</td>
    </tr>
    <tr>
      <td>DROP DUPLICATES</td>
      <td>Relational</td>
      <td>Remove duplicate rows</td>
    </tr>
    <tr>
      <td>GROUPBY</td>
      <td>Relational</td>
      <td>Group rows by column values</td>
    </tr>
    <tr>
      <td>SORT</td>
      <td>Relational</td>
      <td>Reorder rows</td>
    </tr>
    <tr>
      <td>RENAME</td>
      <td>Relational</td>
      <td>Rename columns</td>
    </tr>
    <tr>
      <td>WINDOW</td>
      <td>SQL</td>
      <td>Sliding-window functions</td>
    </tr>
    <tr>
      <td>TRANSPOSE</td>
      <td>Dataframe</td>
      <td>Swap rows and columns</td>
    </tr>
    <tr>
      <td>MAP</td>
      <td>Dataframe</td>
      <td>Apply a function to every row</td>
    </tr>
    <tr>
      <td>TOLABELS</td>
      <td>Dataframe</td>
      <td>Promote data to column/row labels</td>
    </tr>
    <tr>
      <td>FROMLABELS</td>
      <td>Dataframe</td>
      <td>Demote labels back to data</td>
    </tr>
  </tbody>
</table>

<p>The “Origin” column matters. The first nine operators come from relational algebra and have direct analogs in SQL. WINDOW comes from SQL extensions. The last four (TRANSPOSE, MAP, TOLABELS, FROMLABELS) are unique to dataframes. They exist because dataframes treat rows and columns symmetrically and allow data to move between values and metadata. Relational databases can’t do that.</p>

<p>Petersohn showed that over 85% of the pandas API can be rewritten as compositions of these operators. Operations like <code class="language-plaintext highlighter-rouge">fillna</code>, <code class="language-plaintext highlighter-rouge">isnull</code>, <code class="language-plaintext highlighter-rouge">str.upper</code>, and <code class="language-plaintext highlighter-rouge">cummax</code> are all special cases of MAP. Operations like <code class="language-plaintext highlighter-rouge">sort_values</code>, <code class="language-plaintext highlighter-rouge">set_index</code>, <code class="language-plaintext highlighter-rouge">reset_index</code>, <code class="language-plaintext highlighter-rouge">merge</code>, <code class="language-plaintext highlighter-rouge">groupby</code>, and <code class="language-plaintext highlighter-rouge">pivot</code> all map one-to-one onto operators in the algebra. That’s a huge compression: 200+ ad hoc methods become 15 composable primitives.</p>

<p>But I kept looking at the relational operators in that table (PROJECTION, RENAME, GROUPBY, JOIN) and thinking: these feel related. They all change the schema of the dataframe. Is there a deeper relationship?</p>

<h2 id="shapes-of-schema-change">Shapes of schema change</h2>

<p>I kept staring at Petersohn’s table, and a pattern emerged. Some operators change the schema, meaning which columns exist and what types they have. Others leave the schema alone and only affect the rows. And if you focus on the schema-changing ones, they fall into three groups.</p>

<p><strong>Restructuring.</strong> You rearrange, subset, or relabel columns. The data stays the same; only the shape changes. In SQL terms: <code class="language-plaintext highlighter-rouge">SELECT name, salary FROM employees</code> produces a two-column result from a three-column table. In the dataframe library:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">select</span> <span class="p">[</span><span class="s">"name"</span><span class="p">,</span> <span class="s">"salary"</span><span class="p">]</span> <span class="n">df</span>
<span class="c1">-- 3-column schema → 2-column schema</span>

<span class="n">rename</span> <span class="s">"salary"</span> <span class="s">"pay"</span> <span class="n">df</span>
<span class="c1">-- Column name changes, data untouched</span>

<span class="n">exclude</span> <span class="p">[</span><span class="s">"department"</span><span class="p">]</span> <span class="n">df</span>
<span class="c1">-- Drop a column</span>
</code></pre></div></div>

<p>This covers Petersohn’s PROJECTION and RENAME. The output schema is a function of the input schema. You can compute it without looking at any data.</p>

<p><strong>Merging.</strong> You collapse rows that share a key, either into a summary or a collection. In SQL: <code class="language-plaintext highlighter-rouge">SELECT department, AVG(salary) FROM employees GROUP BY department</code>. In the library:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregate</span>
    <span class="p">[</span> <span class="n">mean</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Double</span> <span class="s">"salary"</span><span class="p">)</span> <span class="p">`</span><span class="n">as</span><span class="p">`</span> <span class="s">"avg_salary"</span>
    <span class="p">,</span> <span class="n">count</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Text</span> <span class="s">"name"</span><span class="p">)</span> <span class="p">`</span><span class="n">as</span><span class="p">`</span> <span class="s">"headcount"</span>
    <span class="p">]</span>
    <span class="p">(</span><span class="n">groupBy</span> <span class="p">[</span><span class="s">"department"</span><span class="p">]</span> <span class="n">df</span><span class="p">)</span>
<span class="c1">-- Schema: name, department, salary</span>
<span class="c1">--       → department, avg_salary, headcount</span>

<span class="c1">-- Or keep all values without reducing:</span>
<span class="n">aggregate</span>
    <span class="p">[</span> <span class="n">collect</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Double</span> <span class="s">"salary"</span><span class="p">)</span> <span class="p">`</span><span class="n">as</span><span class="p">`</span> <span class="s">"all_salaries"</span> <span class="p">]</span>
    <span class="p">(</span><span class="n">groupBy</span> <span class="p">[</span><span class="s">"department"</span><span class="p">]</span> <span class="n">df</span><span class="p">)</span>
<span class="c1">-- Each department gets a list of all its salaries</span>
</code></pre></div></div>

<p>Multiple rows map to the same key and get combined. This covers Petersohn’s GROUPBY and UNION.</p>

<p><strong>Pairing.</strong> You find rows in two tables that agree on a shared key and stitch them into a wider row. In SQL: <code class="language-plaintext highlighter-rouge">SELECT * FROM employees INNER JOIN departments USING (department)</code>. In the library:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">innerJoin</span> <span class="p">[</span><span class="s">"department"</span><span class="p">]</span> <span class="n">employees</span> <span class="n">departments</span>
<span class="c1">-- Schema: (name, department, salary) + (department, budget)</span>
<span class="c1">--       → name, department, salary, budget</span>
</code></pre></div></div>

<p>Shared keys appear once; unique columns from each side are concatenated. Left and outer joins are the same idea but with nullable columns where matches might be missing. This covers Petersohn’s CROSS PRODUCT / JOIN.</p>

<p><strong>What doesn’t fit.</strong> Two relational operators resist this grouping. In SQL: <code class="language-plaintext highlighter-rouge">SELECT * FROM employees EXCEPT SELECT * FROM contractors</code> returns rows in one table but not the other. And <code class="language-plaintext highlighter-rouge">SELECT DISTINCT * FROM employees</code> collapses duplicate rows. In the library:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">distinct</span> <span class="n">df</span>
<span class="c1">-- Same schema, fewer rows: removes duplicates</span>

<span class="c1">-- DIFFERENCE is not yet implemented, but the idea is:</span>
<span class="c1">-- difference employees contractors</span>
<span class="c1">-- Same schema, fewer rows: removes rows present in both</span>
</code></pre></div></div>

<p>DIFFERENCE and DROP DUPLICATES both change which rows are present, but they don’t restructure columns, collapse by key, or pair across tables. They feel set-theoretic: one computes a complement, the other computes an image. For now I’ll set them aside, but they’ll reappear when the categorical picture gets sharper.</p>

<p>So five of Petersohn’s relational operators (PROJECTION, RENAME, GROUPBY, UNION, JOIN) map cleanly onto three patterns: restructuring, merging, pairing. The schema-preserving operators (SELECTION, SORT, WINDOW) are orthogonal. They change which rows you see or in what order, but not the column structure. And the dataframe-specific operators (TRANSPOSE, MAP, TOLABELS, FROMLABELS) live outside the relational model entirely.</p>

<p>I had three patterns and two outliers. What I didn’t have was a reason why it should be these three patterns. Are restructuring, merging, and pairing truly fundamental, or did I just happen to group things this way? Where do DIFFERENCE and DROP DUPLICATES belong? Is there a theory that predicts all of this?</p>

<p>That’s the question my mentor Sam Stites pointed me toward when he suggested I read Fong and Spivak’s <em>Seven Sketches in Compositionality</em>. The answer turns out to come from category theory. The version of category theory in Chapter 3 of that book is concrete and database-flavored. You don’t need to know abstract math. You just need to think carefully about what a schema is and what it means to change one.</p>

<h2 id="why-three-migration-functors">Why three migration functors</h2>

<p>Let’s start with a concrete example. Imagine you have two schemas:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Employees: name, department, salary
Departments: department, budget
</code></pre></div></div>

<p>There’s a natural relationship between them. The <code class="language-plaintext highlighter-rouge">department</code> column in <code class="language-plaintext highlighter-rouge">Employees</code> references the <code class="language-plaintext highlighter-rouge">department</code> column in <code class="language-plaintext highlighter-rouge">Departments</code>. That’s a foreign key. It’s a mapping from one schema to the other: every employee row points at a department row.</p>

<p>Now, what can you do with that mapping? Fong and Spivak’s Chapter 3 identifies three fundamental operations.</p>

<p><strong>You can restrict data to fit a different schema.</strong> The schema <code class="language-plaintext highlighter-rouge">{name, salary}</code> is a subset of <code class="language-plaintext highlighter-rouge">Employees</code>. When you call <code class="language-plaintext highlighter-rouge">select ["name", "salary"]</code>, you’re saying: I have data shaped like the full schema, but I only want the part that fits this smaller schema. The data doesn’t change. You’re just dropping the columns that aren’t in the subset.</p>

<p>What makes this general is the idea of a <em>mapping between schemas</em>. For <code class="language-plaintext highlighter-rouge">select</code>, the mapping says “the column <code class="language-plaintext highlighter-rouge">name</code> in the small schema corresponds to <code class="language-plaintext highlighter-rouge">name</code> in the big schema, and <code class="language-plaintext highlighter-rouge">salary</code> corresponds to <code class="language-plaintext highlighter-rouge">salary</code>.” That’s an inclusion. For <code class="language-plaintext highlighter-rouge">rename "salary" "pay"</code>, the mapping says “<code class="language-plaintext highlighter-rouge">pay</code> in the new schema corresponds to <code class="language-plaintext highlighter-rouge">salary</code> in the old one.” That’s a relabeling. In both cases, there’s a mapping that tells you how to translate between schemas, and the data follows.</p>

<p>Fong and Spivak call this <strong>Delta (Δ)</strong>. Given a mapping between schemas, Delta uses it to reshape the data. It never invents new data or combines rows. It only restructures what’s already there.</p>

<p><strong>You can collapse data along the mapping by merging.</strong> Multiple employees share a department. If you want data shaped like the <code class="language-plaintext highlighter-rouge">Departments</code> schema (one row per department) you have to decide what to do with all the employees that point at the same department. You could collect them into a list, sum their salaries, or count them. That’s <code class="language-plaintext highlighter-rouge">groupBy ["department"]</code> followed by an aggregation.</p>

<p>Fong and Spivak call this <strong>Sigma (Σ)</strong>. Given a mapping where many source rows point at the same target, Sigma collects everything at each target. The <code class="language-plaintext highlighter-rouge">collect</code> function keeps all values as a list, which is raw Sigma. Using <code class="language-plaintext highlighter-rouge">sum</code> or <code class="language-plaintext highlighter-rouge">mean</code> composes the collection with a fold.</p>

<p><strong>You can combine data from both schemas by pairing.</strong> Given data on both <code class="language-plaintext highlighter-rouge">Employees</code> and <code class="language-plaintext highlighter-rouge">Departments</code>, you can find rows that agree on <code class="language-plaintext highlighter-rouge">department</code> and stitch them into a wider row. That’s <code class="language-plaintext highlighter-rouge">innerJoin ["department"] employees departments</code>. Each result row contains columns from both tables, matched on their shared key.</p>

<p>Fong and Spivak call this <strong>Pi (Π)</strong>. Their mnemonic is “pair and query data,” with a footnote: “more commonly called ‘join’ by database programmers.” Pi finds all tuples that satisfy the constraints imposed by the shared key.</p>

<p>So the three patterns from the previous section (restructuring, merging, pairing) have names: Δ, Σ, Π. But the names are the least interesting part. What’s interesting is why these three arise naturally from the structure of schema mappings.</p>

<p>The answer is about how schemas relate to each other. Fong and Spivak model schemas as <em>categories</em>. A category here is a collection of things (tables, columns) with relationships between them (foreign keys) and a rule for following chains of relationships.</p>

<p>An <em>instance</em> of a schema is what you get when you assign actual data to it. Each table gets a set of rows. Each foreign key gets a function mapping a row to the row it references.</p>

<p>Take the <code class="language-plaintext highlighter-rouge">Employees</code>/<code class="language-plaintext highlighter-rouge">Departments</code> schema. An instance assigns rows <code class="language-plaintext highlighter-rouge">{Alice, Bob, Carol}</code> to <code class="language-plaintext highlighter-rouge">Employees</code>, rows <code class="language-plaintext highlighter-rouge">{Engineering, Sales}</code> to <code class="language-plaintext highlighter-rouge">Departments</code>, and a function that sends Alice → Engineering, Bob → Sales, Carol → Engineering. The only rule is consistency. If <code class="language-plaintext highlighter-rouge">Employees</code> references <code class="language-plaintext highlighter-rouge">Departments</code> which references <code class="language-plaintext highlighter-rouge">Location</code>, looking up Alice’s location directly has to give the same answer as looking up her department and then that department’s location. In category theory, an instance that satisfies this rule is called a <em>functor</em>.</p>

<p>When you have a mapping between two schemas (also a functor), Fong and Spivak prove that it induces these three data migration operations. They’re connected by a structure called an <em>adjoint triple</em>:</p>

<p><strong>Σ ⊣ Δ ⊣ Π</strong></p>

<p>Sigma is the most generous: take everything and merge. Pi is the most conservative: only keep tuples that match on all shared attributes. Delta goes the other direction, restricting data without inventing or combining anything. The adjunction means these three compose cleanly: the output of any Δ step is a valid input for any Σ or Π step, and vice versa. That’s why you can chain select into join into groupBy and the schemas just work out.</p>

<h2 id="what-the-adjoint-triple-doesnt-cover">What the adjoint triple doesn’t cover</h2>

<p>The adjoint triple accounts for five of Petersohn’s seven relational operators. But DIFFERENCE and DROP DUPLICATES, the two I set aside earlier, don’t arise from schema morphisms at all. They operate on instances of the same schema, not on migrations between schemas.</p>

<p>Think about what DIFFERENCE actually does. Suppose you have two dataframes with the same schema:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>all_employees:           terminated:
name   department        name   department
Alice  Engineering       Carol  Engineering
Bob    Sales
Carol  Engineering
</code></pre></div></div>

<p>DIFFERENCE returns the rows in <code class="language-plaintext highlighter-rouge">all_employees</code> that don’t appear in <code class="language-plaintext highlighter-rouge">terminated</code>: Alice and Bob. The schema doesn’t change. Only the set of rows changes. And DROP DUPLICATES works on a single dataframe:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>with_duplicates:         after distinct:
name   department        name   department
Alice  Engineering       Alice  Engineering
Bob    Sales             Bob    Sales
Alice  Engineering
</code></pre></div></div>

<p>It collapses identical rows into one. Again, the schema is untouched.</p>

<p>The adjoint triple has nothing to say about them because Δ, Σ, and Π are about moving data between schemas. DIFFERENCE and DROP DUPLICATES are about reasoning about subsets of rows within a single schema.</p>

<p>To handle operations on subsets, you need your category to support a notion of “subset” with operations like complement and intersection. Not every category does. But the category of instances on a schema does, because assigning sets of rows to each table and functions to each foreign key gives you all the set-theoretic structure you need. In category theory, a category with this kind of structure is called a <em>topos</em>. The important thing about a topos for our purposes is that it comes equipped with tools for working with subsets:</p>

<ul>
  <li>
    <p><strong>DIFFERENCE</strong> is a complement operation on subsets. Given two subsets of rows A and B (two dataframes with the same schema), DIFFERENCE computes the rows in A that are not in B. A topos guarantees that this complement is well-defined and behaves the way you’d expect from set theory.</p>
  </li>
  <li>
    <p><strong>DROP DUPLICATES</strong> is an image operation. Think of each row’s content as a function from the row’s identity to its values. Multiple rows can map to the same values. DROP DUPLICATES collapses them, keeping one representative per distinct value. A topos guarantees that every such function factors cleanly through its image, which is what makes DROP DUPLICATES well-defined.</p>
  </li>
</ul>

<p>So the categorical picture of relational operations has two layers: the <strong>migration functors</strong> (Δ, Σ, Π) for moving data across schemas, and the <strong>topos structure</strong> for reasoning about subsets of rows within a schema.</p>

<p>Here’s the full picture, mapping Petersohn’s operators to the categorical framework:</p>

<table>
  <thead>
    <tr>
      <th>Petersohn operator</th>
      <th>Pattern</th>
      <th>Category theory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PROJECTION</td>
      <td>Restructuring</td>
      <td>Delta (Δ)</td>
    </tr>
    <tr>
      <td>RENAME</td>
      <td>Restructuring</td>
      <td>Delta (Δ)</td>
    </tr>
    <tr>
      <td>GROUPBY</td>
      <td>Merging</td>
      <td>Sigma (Σ)</td>
    </tr>
    <tr>
      <td>UNION</td>
      <td>Merging</td>
      <td>Sigma (Σ)</td>
    </tr>
    <tr>
      <td>CROSS PRODUCT / JOIN</td>
      <td>Pairing</td>
      <td>Pi (Π)</td>
    </tr>
    <tr>
      <td>DIFFERENCE</td>
      <td>(set-theoretic)</td>
      <td>Subobject complement (topos)</td>
    </tr>
    <tr>
      <td>DROP DUPLICATES</td>
      <td>(set-theoretic)</td>
      <td>Image factorization (topos)</td>
    </tr>
    <tr>
      <td>SELECTION</td>
      <td>(schema-preserving)</td>
      <td>Natural transformation</td>
    </tr>
    <tr>
      <td>SORT</td>
      <td>(schema-preserving)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>WINDOW</td>
      <td>(schema-preserving)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>TRANSPOSE</td>
      <td>(dataframe-specific)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>MAP</td>
      <td>(dataframe-specific)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>TOLABELS</td>
      <td>(dataframe-specific)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>FROMLABELS</td>
      <td>(dataframe-specific)</td>
      <td>—</td>
    </tr>
  </tbody>
</table>

<p>The first five operators decompose into the adjoint triple Δ, Σ, Π. The next two use the topos structure of the category of instances. Together, these seven account for the relational core. The next three preserve the schema. They’re morphisms between instances on the same schema, not migrations between schemas. The last four are outside the relational model entirely.</p>

<p>This is the compression: 200 pandas operators → 15 algebraic operators → 3 migration functors and 2 topos-theoretic operations covering the relational core. The schema-preserving and dataframe-specific operators are important, but they’re not where the complexity lives. The migration functors handle schema-changing operations, and the topos structure handles set-theoretic reasoning within a schema.</p>

<h2 id="designing-an-api-around-these-patterns">Designing an API around these patterns</h2>

<p>The categorical decomposition gives you a design principle: each operation should have a clear rule for computing its output schema from its input schema. The migration functors change the schema. The topos-theoretic operations preserve the schema but change which rows are present.</p>

<p>Start with the migration functors. Delta gives you operations that reshape a schema without computing new data. That means:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">select columns</code> → output schema is the subset of input columns you named</li>
  <li><code class="language-plaintext highlighter-rouge">exclude columns</code> → output schema is the input minus the columns you named</li>
  <li><code class="language-plaintext highlighter-rouge">rename old new</code> → output schema is the input with one column relabeled</li>
</ul>

<p>These operations share a property: given the input schema and the operation’s arguments, you can compute the output schema without looking at any data. That makes them cheap, predictable, and safe to reorder in an optimizer.</p>

<p>Then Sigma. You need operations that collapse rows by key. That means:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">groupBy keys</code> followed by <code class="language-plaintext highlighter-rouge">aggregate [sum ..., mean ..., count ...]</code> → output schema is the key columns plus one new column per aggregation</li>
  <li><code class="language-plaintext highlighter-rouge">groupBy keys</code> followed by <code class="language-plaintext highlighter-rouge">collect</code> → output schema is the key columns plus list-valued columns</li>
</ul>

<p>The key insight from the categorical picture is that <code class="language-plaintext highlighter-rouge">collect</code> and <code class="language-plaintext highlighter-rouge">aggregate</code> are variations of the same pattern. Sigma collects everything at each key, and aggregation functions like <code class="language-plaintext highlighter-rouge">sum</code> or <code class="language-plaintext highlighter-rouge">mean</code> are an optional reduction on top.</p>

<p>Then Pi. You need operations that combine two schemas by shared key. That means:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">innerJoin keys left right</code> → output schema is key columns (once) plus non-key columns from both sides</li>
  <li><code class="language-plaintext highlighter-rouge">leftJoin keys left right</code> → same, but right-side non-key columns become nullable</li>
  <li><code class="language-plaintext highlighter-rouge">fullOuterJoin keys left right</code> → same, but non-key columns from both sides become nullable</li>
</ul>

<p>Each join variant is Pi with a different policy for missing matches. The schema rule is the same; only the nullability wrapping changes.</p>

<p>Then the topos layer. DIFFERENCE and DROP DUPLICATES don’t need schema rules because they preserve the schema. Their type signatures reflect this:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">distinct</span> <span class="o">::</span> <span class="kt">TypedDataFrame</span> <span class="n">cols</span> <span class="o">-&gt;</span> <span class="kt">TypedDataFrame</span> <span class="n">cols</span>
<span class="c1">-- difference :: TypedDataFrame cols -&gt; TypedDataFrame cols -&gt; TypedDataFrame cols</span>
</code></pre></div></div>

<p>The input and output types are identical. What changes is which rows are present: <code class="language-plaintext highlighter-rouge">distinct</code> collapses duplicates, and <code class="language-plaintext highlighter-rouge">difference</code> removes rows that appear in the second argument. Both take a dataframe and return a dataframe with the same columns but fewer rows, which makes their semantics straightforward to implement and optimize.</p>

<p>Finally, the schema-preserving operations (<code class="language-plaintext highlighter-rouge">filter</code>, <code class="language-plaintext highlighter-rouge">sort</code>, <code class="language-plaintext highlighter-rouge">take</code>, <code class="language-plaintext highlighter-rouge">sample</code>) sit outside the migration and topos patterns. Their output schema is always identical to their input schema. They’re important, but they don’t interact with schema composition, which is why they can be designed independently.</p>

<p>Once you have these pieces, a pipeline is a chain of migration steps (Δ, Σ, Π) and row-level steps (DIFFERENCE, DROP DUPLICATES, filter), and each step’s output schema is a valid input for the next. In the dataframe library, Haskell’s type system enforces this. Schemas are encoded at the type level, and the compiler checks every transition:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Employees</span> <span class="o">=</span>
    <span class="n">'</span><span class="p">[</span> <span class="kt">Column</span> <span class="s">"name"</span> <span class="kt">Text</span>
     <span class="p">,</span> <span class="kt">Column</span> <span class="s">"department"</span> <span class="kt">Text</span>
     <span class="p">,</span> <span class="kt">Column</span> <span class="s">"salary"</span> <span class="kt">Double</span>
     <span class="p">]</span>

<span class="kr">type</span> <span class="kt">Departments</span> <span class="o">=</span> <span class="n">'</span><span class="p">[</span> <span class="kt">Column</span> <span class="s">"department"</span> <span class="kt">Text</span><span class="p">,</span> <span class="kt">Column</span> <span class="s">"budget"</span> <span class="kt">Double</span> <span class="p">]</span>

<span class="n">result</span> <span class="o">=</span>
    <span class="n">employees</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">distinct</span>                                <span class="c1">-- topos: drop duplicate rows</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">innerJoin</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"department"</span><span class="p">]</span> <span class="n">departments</span>  <span class="c1">-- Π: schema grows</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">derive</span> <span class="o">@</span><span class="s">"cost_ratio"</span>                    <span class="c1">-- grows by one</span>
            <span class="p">(</span><span class="kt">T</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"salary"</span> <span class="o">/</span> <span class="kt">T</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"budget"</span><span class="p">)</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">select</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"department"</span><span class="p">,</span> <span class="s">"cost_ratio"</span><span class="p">]</span>   <span class="c1">-- Δ: schema shrinks</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">groupBy</span> <span class="o">@</span><span class="n">'</span><span class="p">[</span><span class="s">"department"</span><span class="p">]</span>
        <span class="o">&amp;</span> <span class="kt">T</span><span class="o">.</span><span class="n">aggregate</span>                               <span class="c1">-- Σ: collapse</span>
            <span class="p">(</span> <span class="kt">T</span><span class="o">.</span><span class="n">agg</span> <span class="o">@</span><span class="s">"avg_ratio"</span> <span class="p">(</span><span class="kt">T</span><span class="o">.</span><span class="n">mean</span> <span class="p">(</span><span class="kt">T</span><span class="o">.</span><span class="n">col</span> <span class="o">@</span><span class="s">"cost_ratio"</span><span class="p">))</span>
            <span class="o">$</span> <span class="kt">T</span><span class="o">.</span><span class="n">aggNil</span>
            <span class="p">)</span>
</code></pre></div></div>

<p>Reference <code class="language-plaintext highlighter-rouge">"salary"</code> after <code class="language-plaintext highlighter-rouge">select</code> drops it? Compile error. Derive a column that already exists? Compile error. Join on a key missing from one table? Compile error. If the pipeline compiles, every schema transition is valid. This isn’t Haskell-specific. Any language with sufficiently expressive types could enforce the same rules. The categorical decomposition tells you what the rules are; the type system enforces them.</p>

<p>The patterns also help with optimization. If you know exactly what each operation does to the schema, you can reason about when it’s safe to reorder steps. The library has a lazy evaluation mode where a pipeline is built up as a logical plan before being executed:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">optimize</span> <span class="o">::</span> <span class="kt">Int</span> <span class="o">-&gt;</span> <span class="kt">LogicalPlan</span> <span class="o">-&gt;</span> <span class="kt">PhysicalPlan</span>
<span class="n">optimize</span> <span class="n">batchSz</span> <span class="o">=</span>
    <span class="n">toPhysical</span> <span class="n">batchSz</span>
        <span class="o">.</span> <span class="n">eliminateDeadColumns</span>
        <span class="o">.</span> <span class="n">pushPredicates</span>
        <span class="o">.</span> <span class="n">fuseFilters</span>
</code></pre></div></div>

<p>Consecutive filters get fused into one. Filters get pushed past column operations toward the data source. Derived columns that are never referenced downstream get dropped before execution. These rewrites are safe because the operations obey algebraic laws that follow from the categorical structure: restructuring and filtering commute when the filter doesn’t touch restructured columns; conjunction of predicates is the same as filtering twice; a Δ step that drops a column can’t affect a filter that doesn’t reference it.</p>

<h2 id="where-this-is-going">Where this is going</h2>

<p>What I’m working toward is a canonical definition of the dataframe. Petersohn et al. made the best attempt I’ve seen with their data model and algebra. Category theory adds structure on top: three migration functors for schema-changing operations, and topos structure for set-theoretic reasoning within a schema. Together, these cover the relational core.</p>

<p>That’s what I wanted when I started the library. A small set of operations grounded in theory, with the compiler verifying every step. This post covers the relational operators, the ones dataframes share with SQL. The dataframe-specific operators (TRANSPOSE, TOLABELS, FROMLABELS) and the symmetry between rows and columns deserve their own treatment. But for the relational core, the two-layer picture works.</p>

<p>If any of this sounds interesting: Fong and Spivak’s <a href="https://arxiv.org/abs/1803.05316"><em>Seven Sketches in Compositionality</em></a> is written for non-mathematicians and builds from first principles. Chapter 3 covers databases and the Δ/Σ/Π migration functors. Petersohn et al.’s <a href="https://arxiv.org/abs/2001.00888"><em>Towards Scalable Dataframe Systems</em></a> covers the algebra, the data model, and what people actually do in those 1 million notebooks. Both are worth your time.</p>

<p>The dataframe library is <a href="https://github.com/DataHaskell/dataframe">on GitHub</a>. The typed API lives in <code class="language-plaintext highlighter-rouge">DataFrame.Typed</code>. I’d love to hear what you build with it.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Every dataframe library ships with hundreds of operations. pandas alone has over 200 methods on a DataFrame. Is pivot different from melt? Is apply different from map? What about transform, agg, applymap, pipe? Some of these seem like the same operation wearing different hats. Others seem genuinely distinct. Without a framework for telling them apart, you end up memorizing APIs instead of understanding structure.]]></summary></entry><entry><title type="html">Learning better decision tree splits - LLMs as Heuristics for Program Synthesis</title><link href="mchav.github.io/learning-better-decision-tree-splits/" rel="alternate" type="text/html" title="Learning better decision tree splits - LLMs as Heuristics for Program Synthesis" /><published>2026-01-16T00:00:00+00:00</published><updated>2026-01-16T00:00:00+00:00</updated><id>mchav.github.io/learning-better-decision-tree-splits</id><content type="html" xml:base="mchav.github.io/learning-better-decision-tree-splits/"><![CDATA[<p>A lot of tabular modeling gets easier the moment you stumble onto the right derived quantity. Not something mysterious or “deep.” It’s usually something you can name: a ratio that turns two raw columns into a rate; a difference that becomes a margin; a simple count that captures what a bunch of messy fields were hinting at.</p>

<p>If you’ve done this enough times, you start to recognize the repeating shapes: “per unit,” “per time,” “per person,” “net,” “remaining,” “rate.” These show up everywhere because they match how the world is measured.</p>

<p>The part I’ve been curious about is: how much of this can we automate without losing that “nameable quantity” feeling?</p>

<p>This post is about an experiment that worked surprisingly well for me. I treat feature engineering as a tiny program synthesis problem: enumerate candidate derived features as small arithmetic expressions over columns. Then I train a decision tree using rules built from those derived features. The twist is how I keep the feature generator from spiraling into unintelligible math: I use an LLM to prune out feature combinations that don’t make sense.</p>

<p>The LLMs isn’t the model making the modelling decisions. The LLM’s job is much smaller: look at a candidate arithmetic expression and answer one question—does this resemble a meaningful real-world quantity a human might compute?</p>

<p>That one constraint was enough to turn a messy tree into something that looks a lot more like human feature engineering.</p>

<p><a href="https://github.com/mchav/dataframe/blob/main/app/README.md">You can clone the repo and try it out yourself</a>.</p>

<h2 id="features-and-meaning">Features and meaning</h2>

<p>In practice, the strongest features are the ones you can explain simply. To yourself and to others.</p>

<p>If I write down <code class="language-plaintext highlighter-rouge">clicks / impressions</code>, I can call it “conversion rate.” If I write down <code class="language-plaintext highlighter-rouge">price / sqft</code>, I can call it “price per square foot.” If I write down <code class="language-plaintext highlighter-rouge">revenue - costs</code>, I can call it “profit.”</p>

<p>Even when those aren’t perfect, they’re at least coherent. They’re quantities that exist in the world.</p>

<p>Now compare that to something like <code class="language-plaintext highlighter-rouge">income / zip_code</code>. It might correlate with house price (New York incomes are higher and would be scaled down less because they have smaller valued zip codes compared to Texas, for example). It might even boost accuracy. But it’s not a quantity anyone would compute. It’s a red flag that the search procedure is exploiting quirks in the dataset instead of discovering “features.”</p>

<p>This distinction—between “works statistically” and “is a coherent quantity” is exactly what I wanted to encode into the feature generator.</p>

<h2 id="the-demo-dataset">The demo dataset</h2>

<p>I used Kaggle’s Titanic dataset because it’s familiar and because there’s a well-known “starter pack” of feature engineering that people do. In particular, extracting a title from the passenger name (“Mr.”, “Mrs.”, etc.), deriving a cabin prefix (the leading letter), and doing something with ticket prefixes are all common moves.</p>

<p>I wanted this demo to feel honest: do the obvious parsing that many Titanic solutions do, then stop. Let the system take it from there.</p>

<p><strong>The full pipeline I ran is in <a href="https://github.com/mchav/dataframe/blob/main/app/Synthesis.hs">this file</a>.</strong></p>

<p>A couple things are doing quiet work here.</p>

<p>First: I’m renaming columns so the output reads like English. That matters more than it sounds. If the whole point is interpretability, the printed artifact shouldn’t feel like you’re decoding abbreviations.</p>

<p>Second: notice <code class="language-plaintext highlighter-rouge">maxExprDepth = 2</code>. That’s the “I want features I can still name” constraint. Depth 3 can be fun, but depth 2 is usually where you get ratios, differences, and simple interactions without the expression turning into algebra homework.</p>

<p>Third: I set <code class="language-plaintext highlighter-rouge">complexityPenalty = 0</code> in this run on purpose. I wanted to isolate the effect of the LLM filter. In other words: “what happens if the only regularization pressure is semantic?”</p>

<h2 id="the-feature-generator">The feature generator</h2>

<p>The mechanics are simple. I generate candidate numeric expressions from the columns. Things like <code class="language-plaintext highlighter-rouge">a + b</code>, <code class="language-plaintext highlighter-rouge">a / b</code>, <code class="language-plaintext highlighter-rouge">a - b</code>, <code class="language-plaintext highlighter-rouge">a * b</code>, plus the raw columns themselves. Then I turn each numeric expression into candidate rules by picking thresholds at a small grid of percentiles.</p>

<p>At that point, tree learning is as boring as you want it to be: pick the rule that best improves impurity, split, recurse, stop at depth/min-sample constraints. For classification trees, using something like Gini impurity is standard practice.</p>

<p>So far, this is just automated feature generation feeding a greedy tree. The entire problem is that the expression generator will happily propose nonsense.</p>

<h2 id="the-problem">The problem</h2>

<p>When you enumerate expressions naively, you’ll get candidates like Fare + Age. Strange combinations of class codes and counts that are hard to defend. None of this is illegal. Some of it is even predictive. But it’s not the kind of feature engineering that’s robust or you’d want to explain to someone else. It’s not the kind of artifact you’d want to ship into a risk-sensitive workflow.</p>

<p>This is a classic program synthesis story in miniature: enumerative search grows quickly, and most of the space isn’t interesting. Priors and pruning are the difference between “works” and “does anything you want.”</p>

<p>In my case, the “interesting” region of the search space is: expressions that resemble real derived quantities. Which is a semantic judgment that is awkward to encode by hand. So I outsourced that judgment to a very constrained LLM call.</p>

<h2 id="the-trick-use-an-llm-as-a-semantic-regularizer">The trick: use an LLM as a semantic regularizer</h2>

<p>For each candidate numeric expression, I ask an LLM to output a single digit score from 0 to 10. The prompt includes a short rubric: addition and subtraction should combine like-with-like; multiplication and division can produce useful derived quantities; categorical codes should be treated with suspicion; conversion functions don’t matter.</p>

<p>Then I do the most unglamorous thing possible: I drop expressions that score below a threshold.</p>

<p>That’s it. I’m not asking the LLM to invent features. I’m not asking it to choose thresholds. I’m not asking it to “solve Titanic.” I’m using it like a regularizer: a pressure that biases what the learner is allowed to consider.</p>

<p>This “LLM as guidance for search” framing is showing up more and more in synthesis-style work: use the model to bias exploration, propose edits, or filter candidates, but keep a separate objective that actually evaluates what works.</p>

<p>I like the mental model: the LLM is the bouncer.</p>

<p>The before/after that made me confident this was worth writing down I ran the exact same pipeline twice.</p>

<p>In the first run, I didn’t apply the semantic filter. I just let the candidate generator dump its expression space into the tree learner and let impurity gain decide.</p>

<p>This is the “without LLM” tree I got (translated from the DSL):</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">ifThenElse</span>
    <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Text</span> <span class="s">"title"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Just</span> <span class="s">"Mr."</span><span class="p">)))</span>
    
    <span class="kt">THEN</span><span class="o">:</span>
    <span class="err">├─</span> <span class="n">ifThenElse</span>
    <span class="err">│</span>    <span class="p">(</span><span class="n">or</span>
    <span class="err">│</span>      <span class="p">(</span><span class="n">geq</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"passenger_class"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">2.0</span><span class="p">)))</span>
    <span class="err">│</span>      <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Text</span> <span class="s">"cabin_prefix"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Nothing</span><span class="p">)))</span>
    <span class="err">│</span>    <span class="p">)</span>
    <span class="err">│</span>    
    <span class="err">│</span>    <span class="kt">THEN</span><span class="o">:</span>
    <span class="err">│</span>    <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
    <span class="err">│</span>    
    <span class="err">│</span>    <span class="kt">ELSE</span><span class="o">:</span>
    <span class="err">│</span>    <span class="err">└─</span> <span class="n">ifThenElse</span>
    <span class="err">│</span>         <span class="p">(</span><span class="n">lt</span>
    <span class="err">│</span>           <span class="p">(</span><span class="n">ifThenElse</span>
    <span class="err">│</span>             <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">0.0</span><span class="p">)))</span>
    <span class="err">│</span>             <span class="kt">THEN</span><span class="o">:</span> <span class="n">divide</span>
    <span class="err">│</span>                     <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"passenger_class"</span><span class="p">))</span>
    <span class="err">│</span>                     <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses"</span><span class="p">))</span>
    <span class="err">│</span>             <span class="kt">ELSE</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mf">0.0</span><span class="p">)</span>
    <span class="err">│</span>           <span class="p">)</span>
    <span class="err">│</span>           <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">1.0</span><span class="p">))</span>
    <span class="err">│</span>         <span class="p">)</span>
    <span class="err">│</span>         
    <span class="err">│</span>         <span class="kt">THEN</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
    <span class="err">│</span>         <span class="kt">ELSE</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
    
    <span class="kt">ELSE</span><span class="o">:</span>
    <span class="err">└─</span> <span class="n">ifThenElse</span>
         <span class="p">(</span><span class="n">lt</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"passenger_class"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">3.0</span><span class="p">)))</span>
         
         <span class="kt">THEN</span><span class="o">:</span>
         <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
         
         <span class="kt">ELSE</span><span class="o">:</span>
         <span class="err">└─</span> <span class="n">ifThenElse</span>
              <span class="p">(</span><span class="n">gt</span>
                <span class="p">(</span><span class="n">mult</span>
                  <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_parents_and_children"</span><span class="p">))</span>
                  <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses"</span><span class="p">))</span>
                <span class="p">)</span>
                <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">1.0</span><span class="p">))</span>
              <span class="p">)</span>
              
              <span class="kt">THEN</span><span class="o">:</span>
              <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
              
              <span class="kt">ELSE</span><span class="o">:</span>
              <span class="err">└─</span> <span class="n">ifThenElse</span>
                   <span class="p">(</span><span class="n">and</span>
                     <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Text</span> <span class="s">"Embarked"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Just</span> <span class="s">"S"</span><span class="p">)))</span>
                     <span class="p">(</span><span class="n">geq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Double</span> <span class="s">"Age"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Just</span> <span class="mf">14.650000000000002</span><span class="p">)))</span>
                   <span class="p">)</span>
                   
                   <span class="kt">THEN</span><span class="o">:</span>
                   <span class="err">├─</span> <span class="n">ifThenElse</span>
                   <span class="err">│</span>    <span class="p">(</span><span class="n">gt</span>
                   <span class="err">│</span>      <span class="p">(</span><span class="n">ifThenElse</span>
                   <span class="err">│</span>        <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">0.0</span><span class="p">)))</span>
                   <span class="err">│</span>        <span class="kt">THEN</span><span class="o">:</span> <span class="n">divide</span>
                   <span class="err">│</span>                <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"passenger_class"</span><span class="p">))</span>
                   <span class="err">│</span>                <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses"</span><span class="p">))</span>
                   <span class="err">│</span>        <span class="kt">ELSE</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mf">0.0</span><span class="p">)</span>
                   <span class="err">│</span>      <span class="p">)</span>
                   <span class="err">│</span>      <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">1.0</span><span class="p">))</span>
                   <span class="err">│</span>    <span class="p">)</span>
                   <span class="err">│</span>    <span class="kt">THEN</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
                   <span class="err">│</span>    <span class="kt">ELSE</span><span class="o">:</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
                   
                   <span class="kt">ELSE</span><span class="o">:</span>
                   <span class="err">└─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<p>The DSL is in prefix/Polish notation. So <code class="language-plaintext highlighter-rouge">(eq (col @Maybe Text "title") (lit (Just "Mr.")))</code> means <code class="language-plaintext highlighter-rouge">title == "Mr."</code>. The <code class="language-plaintext highlighter-rouge">Maybe</code> part is just how Haskell deals with nulls.</p>

<p>Even if you don’t read every branch, you can feel the flavor. There are lots of conditions that look like dataset artifacts: dividing passenger class by number of siblings, multiplying number of parents with number of siblings. This is exactly what happens when the search is allowed to treat “anything correlated” as a legitimate feature.</p>

<p>The training accuracy and validation of this decision tree were 0.82.</p>

<p>What happens when we use the LLM?</p>

<h3 id="getting-the-prompt-right">Getting the prompt right</h3>

<p>I started off with this prompt:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Please return nothing else but a single number between 0 and 10 inclusive denoting how interpretable the following expression is and if the operation makes sense given your inferred dimensions of the variables. 0 would be something completely meaningless e.g rgb_house_colour * lot_size and 10 would be something that is a commonly reasoned about quantity e.g price / lot_size. The expression is: fare + age
</code></pre></div></div>

<p>Which was admittedly confusingly written and underspecified. After trying it a number of times in the ollama CLI and getting counterintuitive results (<code class="language-plaintext highlighter-rouge">number_of_people + rejection_rate</code> scored high but <code class="language-plaintext highlighter-rouge">number_of_siblings + number_of_parents</code> scored low), I iterated on the prompt to more thoroughly specify the task. Since ollama doesn’t “think” you have to be careful how you engineer the prompt so it gets a reasonable solution in one shot.</p>

<p>After many iterations I settled with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You must output ONLY a single digit 0-10, nothing else. No explanation, no text, just the number.

Evaluate if this expression produces a meaningful quantity by checking:
    - Do the units/types match for the operation?
    - Is either operand a categorical code rather than a true quantity?
    - Would the result be useful to actually calculate?

Scoring:
    - 0-3: Result is meaningless (e.g., "fare + age" = dollars+years, "height + weight" = meters+kg)
    - 4-5: Unclear or context-dependent meaning
    - 6-7: Makes sense in specific domains
    - 8-9: Clear, commonly useful quantity
    - 10: Fundamental/universal quantity
Guidelines:
    - Addition/subtraction: operands must represent the same kind of thing
    - Multiplication/division: can create meaningful derived quantities
    - Consider: would this result be useful to calculate in practice?
    - `toDouble` is just a function that converts any number to a decimal and is semantically unimportant.

Examples:
    toDouble(fare) + toDouble(age) = 2 (adding money to years)
    toDouble(price) / toDouble(area) = 9 (price per sq ft)
    toDouble(distance) / toDouble(time) = 10 (speed)
    toDouble(num_people) * toDouble(rejection_rate) = 9 (expected rejections)
    toDouble(revenue) - toDouble(costs) = 10 (profit)
    toDouble(height) + toDouble(weight) = 2 (adding length to mass)
Output format: Just the digit, e.g., 2
Think very carefully about each but only give me the final answer.
Expression:
</code></pre></div></div>

<p>Here’s the second tree where candidate expressions are pruned by the LLM if their “semantic” score is less than 5:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">model</span> <span class="o">=</span>
  <span class="n">ifThenElse</span>
    <span class="p">(</span><span class="n">and</span>
      <span class="p">(</span><span class="n">lt</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"ticket_class"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">3.0</span><span class="p">)))</span>
      <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Text</span> <span class="s">"Sex"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="s">"female"</span><span class="p">)))</span>
    <span class="p">)</span>
    
    <span class="kt">THEN</span><span class="o">:</span>
    <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
    
    <span class="kt">ELSE</span><span class="o">:</span>
    <span class="err">└─</span> <span class="n">ifThenElse</span>
         <span class="p">(</span><span class="n">and</span>
           <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Text</span> <span class="s">"cabin_prefix"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Nothing</span><span class="p">)))</span>
           <span class="p">(</span><span class="n">eq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Text</span> <span class="s">"title"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Just</span> <span class="s">"Mr."</span><span class="p">)))</span>
         <span class="p">)</span>
         
         <span class="kt">THEN</span><span class="o">:</span>
         <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
         
         <span class="kt">ELSE</span><span class="o">:</span>
         <span class="err">└─</span> <span class="n">ifThenElse</span>
              <span class="p">(</span><span class="n">geq</span>
                <span class="p">(</span><span class="n">add</span>
                  <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_parents_and_children_aboard"</span><span class="p">))</span>
                  <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"number_of_siblings_and_spouses_aboard"</span><span class="p">))</span>
                <span class="p">)</span>
                <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">3.0</span><span class="p">))</span>
              <span class="p">)</span>
              
              <span class="kt">THEN</span><span class="o">:</span>
              <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
              
              <span class="kt">ELSE</span><span class="o">:</span>
              <span class="err">└─</span> <span class="n">ifThenElse</span>
                   <span class="p">(</span><span class="n">or</span>
                     <span class="p">(</span><span class="n">gt</span> <span class="p">(</span><span class="n">toDouble</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Int</span> <span class="s">"ticket_class"</span><span class="p">))</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="mf">1.0</span><span class="p">)))</span>
                     <span class="p">(</span><span class="n">geq</span> <span class="p">(</span><span class="n">col</span> <span class="o">@</span><span class="kt">Maybe</span> <span class="kt">Double</span> <span class="s">"Age"</span><span class="p">)</span> <span class="p">(</span><span class="n">lit</span> <span class="p">(</span><span class="kt">Just</span> <span class="mi">14</span><span class="p">)))</span>
                   <span class="p">)</span>
                   
                   <span class="kt">THEN</span><span class="o">:</span>
                   <span class="err">├─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">0</span><span class="p">)</span>
                   
                   <span class="kt">ELSE</span><span class="o">:</span>
                   <span class="err">└─</span> <span class="n">lit</span> <span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<p>The initial condition is already immediately readable: women in first or second class survived. Even deeper into the tree it remains readable - large faimiles didn’t survive (encoded in the conditon: if number of parents and siblings is greater than 3 then 0).</p>

<p>The training and validation accuracy of this decision tree were both 0.83. Better than the first model!</p>

<p>More importantly the tree was simpler. The change wasn’t subtle. The tree got smaller and more coherent. It rediscovered features that are basically “human Titanic features” like family size, and simple interactions with sex. In other words: it started behaving like a feature engineering notebook, not like a correlation-miner.</p>

<p>This is the moment the “LLM as regularizer” framing really clicked for me. The learner didn’t change. The objective didn’t change. The only thing that changed was the hypothesis space: which derived quantities were allowed to exist as candidates.</p>

<p>And that was enough to nudge the output from “math that correlates” to “features you can name.”</p>

<p>If you’re reading this and thinking “Titanic is toy-ish,” I agree. I used it because everyone already knows what reasonable features look like, which makes the interpretability comparison crisp. The real test is whether this behavior carries over to messier tabular problems where feature engineering actually hurts.</p>

<h2 id="why-i-think-this-is-a-nice-way-to-use-llms">Why I think this is a nice way to use LLMs</h2>

<p>There are two reasons I like this pattern.</p>

<p>First, it doesn’t throw the entire problem at the LLM hoping it gets it right. The LLM is asked to do semantic triage. That’s a smaller, more stable job.</p>

<p>Second, it treats interpretability as a first-class constraint. If your model is going to be read by humans, you don’t want to optimize only for accuracy and then bolt on interpretability later. You want the candidate language itself to be biased toward human-shaped quantities.</p>

<p>What I have right now is still a prototype, but the shape feels right: a small synthesis loop producing candidate features, a classic learner picking among them, and an LLM providing a soft semantic prior when you don’t have explicit unit metadata.</p>

<h2 id="limitations-the-kind-you-notice-immediately">Limitations (the kind you notice immediately)</h2>

<p>The first limitation is determinism. If you want this to be reproducible, you’ll want caching: expression string → score. You’ll also want deterministic decoding, or at least stable settings.</p>

<p>The second limitation is schema context. The rubric can only be as good as the column names. If the dataset has columns like X1, X2, A, B, you shouldn’t expect semantic miracles. The filter can become much better if you pass minimal schema descriptions (“this is dollars,” “this is years,” “this is a categorical ID”).</p>

<p>The third limitation is that “meaningful quantity” is subjective. There are domains where “Age &lt;= Fare” might encode something real (it’s still weird, but you get the idea). This is why I think of the score as a regularizer rather than a hard rule: it’s a knob you tune based on how strict you want the feature language to be.</p>

<h2 id="what-i-want-to-try-next">What I want to try next</h2>

<p>The next steps feel straightforward.</p>

<p>One is to distill the LLM into something cheaper. Once you have a cache of labeled expressions, you can train a small local classifier that approximates the semantic score and only calls the LLM for uncertain cases.</p>

<p>Another is to combine semantic regularization with structural regularization. In the demo run above I set the explicit complexity penalty to zero to isolate the LLM effect. In practice, you can (and probably should) use both: penalize expression size while also filtering out expressions that fail a semantic sanity check.</p>

<p>And the most exciting direction, for me, is to apply this to real tabular workflows where the “right derived quantities” aren’t obvious, but still exist: fraud, risk, forecasting, ops metrics, churn, all the places where humans already reason in rates, margins, and per-unit measures.</p>

<p>If nothing else, this experiment made one thing feel concrete: there’s a useful middle ground between “manual feature engineering forever” and “opaque feature learning.” You can automate the search, and still keep the artifact readable, if you treat semantics as something the search needs to respect.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A lot of tabular modeling gets easier the moment you stumble onto the right derived quantity. Not something mysterious or “deep.” It’s usually something you can name: a ratio that turns two raw columns into a rate; a difference that becomes a margin; a simple count that captures what a bunch of messy fields were hinting at.]]></summary></entry><entry><title type="html">Installing docker on a Chromebook</title><link href="mchav.github.io/installing-docker-on-chromebook/" rel="alternate" type="text/html" title="Installing docker on a Chromebook" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>mchav.github.io/installing-docker-on-chromebook</id><content type="html" xml:base="mchav.github.io/installing-docker-on-chromebook/"><![CDATA[<p>I couldn’t find any instructions online so I thought I’d post them here for anyone who goes through a similar struggle.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">sudo </span>apt-get update
<span class="nv">$ </span><span class="nb">sudo </span>apt-get <span class="nb">install </span>ca-certificates curl gnupg
<span class="nv">$ </span><span class="nb">sudo rm</span> <span class="nt">-f</span> /etc/apt/keyrings/docker.gpg <span class="c"># Remove any broken stuff that might be around</span>
<span class="nv">$ </span><span class="nb">sudo mkdir</span> <span class="nt">-p</span> /etc/apt/keyrings
<span class="nv">$ </span>curl <span class="nt">-fsSL</span> https://download.docker.com/linux/debian/gpg | <span class="nb">sudo </span>gpg <span class="nt">--dearmor</span> <span class="nt">-o</span> /etc/apt/keyrings/docker.gpg
<span class="nv">$ </span><span class="nb">sudo chmod </span>a+r /etc/apt/keyrings/docker.gpg
<span class="nv">$ </span><span class="nb">sudo </span>apt-get update
<span class="nv">$ </span><span class="nb">sudo </span>apt-get <span class="nb">install </span>docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
</code></pre></div></div>]]></content><author><name></name></author><summary type="html"><![CDATA[I couldn’t find any instructions online so I thought I’d post them here for anyone who goes through a similar struggle.]]></summary></entry><entry><title type="html">An introduction to program synthesis (Part II) - Automatically generating features for machine learning</title><link href="mchav.github.io/an-introduction-to-program-synthesis-part-ii/" rel="alternate" type="text/html" title="An introduction to program synthesis (Part II) - Automatically generating features for machine learning" /><published>2025-11-04T00:00:00+00:00</published><updated>2025-11-04T00:00:00+00:00</updated><id>mchav.github.io/an-introduction-to-program-synthesis-part-ii</id><content type="html" xml:base="mchav.github.io/an-introduction-to-program-synthesis-part-ii/"><![CDATA[<h2 id="introduction">Introduction</h2>
<p>This post kicks off the second part of a hands-on series about program synthesis. We’ll apply the previously explored technique (an enumerative bottom-up search) to a slightly more realistic problem: automatically generating features for the <a href="https://archive.ics.uci.edu/dataset/53/iris">Iris dataset</a>.</p>

<h2 id="a-recap">A recap</h2>
<p>In the last post, we created a Flash-fill-style system that generates programs that transform strings given input-output examples as specifications. We defined a domain-specific language and searched the space of all possible programs to find one that produced all the inputs for our desired outputs.</p>

<p>This search technique (enumerative bottom-up search) is effectively a breadth-first search. The high-level algorithm is as follows:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">search</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">outputs</span><span class="o">)</span> <span class="o">{</span>
 <span class="n">programs</span> <span class="o">=</span> <span class="n">all_simple_programs</span><span class="o">()</span>
    <span class="k">while</span> <span class="o">(</span><span class="kc">true</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">for</span> <span class="o">(</span><span class="n">program</span> <span class="o">:</span> <span class="n">programs</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">satisfiesExamples</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">outputs</span><span class="o">,</span> <span class="n">program</span><span class="o">))</span> <span class="o">{</span>
                <span class="k">return</span> <span class="n">program</span>
 <span class="o">}</span>
 <span class="o">}</span>
 <span class="n">programs</span> <span class="o">=</span> <span class="n">deduplicate</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">expand</span><span class="o">(</span><span class="n">programs</span><span class="o">))</span>
 <span class="o">}</span>
    <span class="c1">// no program was found</span>
    <span class="k">return</span> <span class="kc">null</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="the-limits-of-this-approach">The limits of this approach</h2>

<h3 id="combinatorial-explosion">Combinatorial explosion</h3>
<p>We tried to deal with the combinatorial explosion by deduplicating and keeping our DSL small. While effective for our toy problem, most real-world problems aggressively resist attempts to be so easily constrained. Interesting problems typically require expressive DSLs (binary and ternary functions, recursive functions, conditional logic, etc.), which make searching much more difficult. Deduplicating expressions also gets difficult as the DSL becomes more expressive.</p>

<p>We’ll have to figure out a way to tame the algorithmic complexity of the problem without hamstringing our search.</p>

<h3 id="what-is-a-correct-program">What is a correct program?</h3>
<p>Checking if a program satisfies our examples works well when we can trust that the examples:</p>
<ul>
  <li>are complete and representative of the problem</li>
  <li>correctly specify the underlying program we are trying to discover.</li>
</ul>

<p>When dealing with data in the wild, we can’t always guarantee that our inputs or outputs are correct. Our data is typically a noisy sub-sample of a larger distribution. Finding the exact program that satisfies a given set of examples might create an inflexible, complicated program that tries to get every single example right. In mathematical modelling, we call this overfitting: when a program fits the training examples but doesn’t generalise well.</p>

<p>We’re going to have to come up with approximate notions of “correctness” that can let us know when we’ve found a program that’s good enough for the problem.</p>

<h2 id="our-problem">Our problem</h2>

<p>In a <a href="https://mchav.github.io/iris-classification-in-haskell/">previous post</a>, we trained a simple neural network to classify plants into three iris species. With some very light, hand-wavy feature engineering, we did well.</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>0.9677419</td>
      <td>0.9375</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>0.9459459</td>
      <td>0.9722222</td>
    </tr>
  </tbody>
</table>

<p>Our test set performance wasn’t as good as we’d hoped.</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>0.9285714</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>0.8888889</td>
      <td>0.8888889</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>0.8666667</td>
      <td>0.9285714</td>
    </tr>
  </tbody>
</table>

<p>Our challenge for this post will be to engineer better features using symbolic regression.</p>

<p>Wait…symbolic regression? What happened to program synthesis? Well, symbolic regression is a kind of program synthesis in which the programs are mathematical expressions, and instead of finding the exact program, we find one that fits a dataset. The underlying algorithm is practically the same:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">search</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">outputs</span><span class="o">,</span> <span class="n">depth</span><span class="o">)</span> <span class="o">{</span>
 <span class="n">formulas</span> <span class="o">=</span> <span class="n">all_simple_formulas</span><span class="o">()</span>
    <span class="k">while</span> <span class="o">(</span><span class="n">depth</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
 <span class="n">forumlas</span> <span class="o">=</span> <span class="n">deduplicate</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">expand</span><span class="o">(</span><span class="n">formulas</span><span class="o">))</span>
 <span class="o">}</span>
    <span class="k">return</span> <span class="nf">pickBestFormula</span><span class="o">(</span><span class="n">inputs</span><span class="o">,</span> <span class="n">outputs</span><span class="o">,</span> <span class="n">formulas</span><span class="o">)</span>
<span class="o">}</span>
</code></pre></div></div>

<p>There are a couple of significant differences between this and our earlier synthesis algorithm.</p>

<ul>
  <li>Our target is uncertain, so we have to constrain our search to only work until a certain depth.</li>
  <li>Because we are looking for a function that best approximates the relationship between the input and output, we have to include some logic to pick the best solution (for some definition of best).</li>
</ul>

<p>This process should yield features we can directly plug into our neural network.</p>

<p>A prototype of this approach exists in the <a href="https://github.com/mchav/dataframe">dataframe library</a>, so this post will explain that code. I have simplified the code for this post.</p>

<h2 id="why-feature-engineering">Why feature engineering?</h2>
<p>Feature engineering is a more straightforward target than finding an exact program (we’d be building a fully fledged classifier). The result of feature engineering must have a strong relationship with the target variable. We’ll see some tools for finding exact expressions later in this post.</p>

<h2 id="our-search-space">Our search space</h2>
<p>The dataframe library defines an expression DSL that looks roughly like this:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="kr">where</span>
 <span class="kt">Col</span> <span class="o">::</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>  <span class="c1">-- A reference to a column in the dataframe</span>
 <span class="kt">Lit</span> <span class="o">::</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>       <span class="c1">-- A literal value</span>
 <span class="kt">UnaryOp</span> <span class="o">::</span> <span class="p">(</span><span class="n">b</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">b</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
 <span class="kt">BinaryOp</span> <span class="o">::</span> <span class="p">(</span><span class="n">c</span> <span class="o">-&gt;</span> <span class="n">b</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">c</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">b</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
</code></pre></div></div>

<p>This DSL is effectively the same expression language we defined in <a href="https://mchav.github.io/rewriting-dataframes-for-microhs/">rewriting dataframes for MicroHs</a>.</p>

<p>We can define some mathematical operations using this DSL.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add</span> <span class="o">::</span> <span class="p">(</span><span class="kt">Num</span> <span class="n">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
<span class="n">add</span> <span class="o">=</span> <span class="kt">BinaryOp</span> <span class="p">(</span><span class="o">+</span><span class="p">)</span>

<span class="n">sub</span> <span class="o">::</span> <span class="p">(</span><span class="kt">Num</span> <span class="n">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
<span class="n">sub</span> <span class="o">=</span> <span class="kt">BinaryOp</span> <span class="p">(</span><span class="o">-</span><span class="p">)</span>

<span class="n">cos</span> <span class="o">::</span> <span class="kt">Num</span> <span class="n">a</span> <span class="o">=&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
<span class="n">cos</span> <span class="o">=</span> <span class="kt">UnaryOp</span> <span class="kt">Prelude</span><span class="o">.</span><span class="n">cos</span>

<span class="o">...</span>
</code></pre></div></div>

<p>These will be our simple programs/formulas (from hereon, I’ll use the two words interchangeably), which we use to seed and grow the search space.</p>

<p>What we call the <code class="language-plaintext highlighter-rouge">expand</code> function in the pseudo code corresponds to the <code class="language-plaintext highlighter-rouge">generatePrograms</code> function in the actual implementation. We define it as such:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">generatePrograms</span> <span class="o">::</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span>
<span class="n">generatePrograms</span> <span class="n">vars</span> <span class="n">constants</span> <span class="kt">[]</span> <span class="o">=</span> <span class="n">vars</span> <span class="o">++</span> <span class="n">constants</span>
<span class="n">generatePrograms</span> <span class="n">vars</span> <span class="n">constants</span> <span class="n">ps</span> <span class="o">=</span>
    <span class="kr">let</span>
 <span class="n">existingPrograms</span> <span class="o">=</span> <span class="n">ps</span> <span class="o">++</span> <span class="n">vars</span> <span class="o">++</span> <span class="n">constants</span>
     <span class="kr">in</span>
 <span class="n">existingPrograms</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">transform</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">ps</span> <span class="o">++</span> <span class="n">vars</span>
 <span class="p">,</span> <span class="n">transform</span> <span class="o">&lt;-</span>
 <span class="p">[</span> <span class="n">sqrt</span>
 <span class="p">,</span> <span class="n">abs</span>
 <span class="p">,</span> <span class="n">log</span> <span class="o">.</span> <span class="p">(</span><span class="o">+</span> <span class="kt">Lit</span> <span class="mi">1</span><span class="p">)</span>
 <span class="p">,</span> <span class="n">exp</span>
 <span class="p">,</span> <span class="n">sin</span>
 <span class="p">,</span> <span class="n">cos</span>
 <span class="p">,</span> <span class="n">relu</span>
 <span class="p">,</span> <span class="n">signum</span>
 <span class="p">]</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">pow</span> <span class="n">i</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">&lt;-</span> <span class="p">[</span><span class="mi">2</span> <span class="o">..</span> <span class="mi">6</span><span class="p">]</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">p</span> <span class="o">+</span> <span class="n">q</span>
 <span class="o">|</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="n">j</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">p</span> <span class="o">-</span> <span class="n">q</span>
 <span class="o">|</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">/=</span> <span class="n">j</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">p</span> <span class="o">*</span> <span class="n">q</span>
 <span class="o">|</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="n">j</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">p</span> <span class="o">/</span> <span class="n">q</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span>
 <span class="p">]</span>
</code></pre></div></div>

<p>At each iteration (except the first), we take the list of existing formulas and compose them with simple programs. For example, when we invoke <code class="language-plaintext highlighter-rouge">generatePrograms [] [] [col "x"]</code> we get <code class="language-plaintext highlighter-rouge">sqrt(x), abs(x),..., pow 0 x, pow 2 x,...,x + x, x * x</code>.</p>

<p>We’ve expanded our initial “formula” into a set of prospective formulas. We already notice some duplicates here. <code class="language-plaintext highlighter-rouge">pow 2 x == x * x</code>. To save ourselves the trouble of an expansion branch, we should keep only one of these.</p>

<h2 id="deduplication">Deduplication</h2>
<p>Mathematics has many tools for simplifying complex structures into canonical forms. For example, <code class="language-plaintext highlighter-rouge">1/3</code>, <code class="language-plaintext highlighter-rouge">2/6</code>, and <code class="language-plaintext highlighter-rouge">4/12</code> can effectively be treated as <code class="language-plaintext highlighter-rouge">1/3</code>. Until now, we have relied on observational equality to deduplicate expressions. Such a deduplication would quickly catch a fair number of these deduplications, but it’s still a relatively expensive notion of equality - especially as the number of formulas grows. We need a way to leverage our regular tools for mathematical equivalence so we don’t always have to evaluate our prospective formulas on the dataframe.</p>

<p>Luckily, this is a solved problem. Symbolic regression implementations typically use equality graphs (e-graphs) to rewrite expressions in their simplest form. E-graphs are a valuable tool in compilers as well, since they can help optimise large expressions into simpler equivalents.</p>

<p>In this example, however, we won’t use equality graphs. For pedagogical reasons, we’ll implement our own custom rewrite rules.</p>

<p>We can do this in two ways:</p>
<ul>
  <li>Bake reduction rules into the expressions of the DSL</li>
  <li>Normalise whole expressions into their canonical forms post-hoc.</li>
</ul>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="p">(</span><span class="o">+</span><span class="p">)</span> <span class="o">::</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
<span class="p">(</span><span class="o">+</span><span class="p">)</span> <span class="p">(</span><span class="kt">Lit</span> <span class="n">x</span><span class="p">)</span> <span class="p">(</span><span class="kt">Lit</span> <span class="n">y</span><span class="p">)</span> <span class="o">=</span> <span class="kt">Lit</span> <span class="p">(</span><span class="n">x</span> <span class="o">+</span> <span class="n">y</span><span class="p">)</span>
<span class="p">(</span><span class="o">+</span><span class="p">)</span> <span class="n">e1</span> <span class="n">e2</span>
    <span class="c1">-- Simple rule that says if we're adding x to itself then rewrite the expression as x * 2</span>
 <span class="o">|</span> <span class="n">e1</span> <span class="o">==</span> <span class="n">e2</span> <span class="o">=</span> <span class="kt">UnaryOp</span> <span class="p">(</span><span class="s">"mult "</span> <span class="o">&lt;&gt;</span> <span class="p">(</span><span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">.</span> <span class="n">show</span><span class="p">)</span> <span class="p">(</span><span class="kt">Lit</span> <span class="o">@</span><span class="n">a</span> <span class="mi">2</span><span class="p">))</span> <span class="p">(</span><span class="o">*</span> <span class="mi">2</span><span class="p">)</span> <span class="n">e1</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="kt">BinaryOp</span> <span class="s">"add"</span> <span class="p">(</span><span class="o">+</span><span class="p">)</span> <span class="n">e1</span> <span class="n">e2</span>


<span class="n">normalize</span> <span class="o">::</span> <span class="p">(</span><span class="kt">Eq</span> <span class="n">a</span><span class="p">,</span> <span class="kt">Ord</span> <span class="n">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kt">Expr</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="n">a</span>
<span class="n">normalize</span> <span class="n">expr</span> <span class="o">=</span> <span class="kr">case</span> <span class="n">expr</span> <span class="kr">of</span>
 <span class="kt">Col</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="kt">Col</span> <span class="n">name</span>
 <span class="kt">Lit</span> <span class="n">val</span> <span class="o">-&gt;</span> <span class="kt">Lit</span> <span class="n">val</span>
 <span class="kt">If</span> <span class="n">cond</span> <span class="n">th</span> <span class="n">el</span> <span class="o">-&gt;</span> <span class="kt">If</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">cond</span><span class="p">)</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">th</span><span class="p">)</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">el</span><span class="p">)</span>
 <span class="kt">UnaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="kt">UnaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">e</span><span class="p">)</span>
 <span class="kt">BinaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="n">e1</span> <span class="n">e2</span>
 <span class="o">|</span> <span class="n">isCommutative</span> <span class="n">name</span> <span class="o">-&gt;</span>
            <span class="kr">let</span> <span class="n">n1</span> <span class="o">=</span> <span class="n">normalize</span> <span class="n">e1</span>
 <span class="n">n2</span> <span class="o">=</span> <span class="n">normalize</span> <span class="n">e2</span>
             <span class="kr">in</span> <span class="kr">if</span> <span class="n">compareExpr</span> <span class="n">n1</span> <span class="n">n2</span> <span class="o">==</span> <span class="kt">GT</span>
                    <span class="kr">then</span> <span class="kt">BinaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="n">n2</span> <span class="n">n1</span> <span class="c1">-- Swap to canonical order</span>
                    <span class="kr">else</span> <span class="kt">BinaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="n">n1</span> <span class="n">n2</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">-&gt;</span> <span class="kt">BinaryOp</span> <span class="n">name</span> <span class="n">f</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">e1</span><span class="p">)</span> <span class="p">(</span><span class="n">normalize</span> <span class="n">e2</span><span class="p">)</span>
</code></pre></div></div>

<p>Of course, these are straightforward optimisations. An e-graph would implement many smart rewrite rules, but we’ll stick with these for now.</p>

<p>The deduplication function can thus be written as:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">deduplicate</span> <span class="o">::</span>
    <span class="kt">DataFrame</span> <span class="o">-&gt;</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="p">[(</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">,</span> <span class="kt">TypedColumn</span> <span class="kt">Double</span><span class="p">)]</span>
<span class="n">deduplicate</span> <span class="n">df</span> <span class="o">=</span> <span class="n">go</span> <span class="kt">S</span><span class="o">.</span><span class="n">empty</span> <span class="o">.</span> <span class="n">nubOrd</span> <span class="o">.</span> <span class="kt">L</span><span class="o">.</span><span class="n">sortBy</span> <span class="p">(</span><span class="nf">\</span><span class="n">e1</span> <span class="n">e2</span> <span class="o">-&gt;</span> <span class="n">compare</span> <span class="p">(</span><span class="n">eSize</span> <span class="n">e1</span><span class="p">)</span> <span class="p">(</span><span class="n">eSize</span> <span class="n">e2</span><span class="p">))</span> <span class="o">.</span> <span class="n">map</span> <span class="n">normalize</span>
  <span class="kr">where</span>
 <span class="n">go</span> <span class="kr">_</span> <span class="kt">[]</span> <span class="o">=</span> <span class="kt">[]</span>
 <span class="n">go</span> <span class="n">seen</span> <span class="p">(</span><span class="n">x</span> <span class="o">:</span> <span class="n">xs</span><span class="p">)</span>
 <span class="o">|</span> <span class="n">hasInvalid</span> <span class="o">=</span> <span class="n">go</span> <span class="n">seen</span> <span class="n">xs</span>
 <span class="o">|</span> <span class="kt">S</span><span class="o">.</span><span class="n">member</span> <span class="n">res</span> <span class="n">seen</span> <span class="o">=</span> <span class="n">go</span> <span class="n">seen</span> <span class="n">xs</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">res</span><span class="p">)</span> <span class="o">:</span> <span class="n">go</span> <span class="p">(</span><span class="kt">S</span><span class="o">.</span><span class="n">insert</span> <span class="n">res</span> <span class="n">seen</span><span class="p">)</span> <span class="n">xs</span>
      <span class="kr">where</span>
 <span class="n">res</span> <span class="o">=</span> <span class="n">either</span> <span class="n">throw</span> <span class="n">id</span> <span class="p">(</span><span class="n">interpret</span> <span class="o">@</span><span class="kt">Double</span> <span class="n">df</span> <span class="n">x</span><span class="p">)</span>
        <span class="c1">-- We need this since we allow functions that might return infinity or NaN</span>
 <span class="n">hasInvalid</span> <span class="o">=</span> <span class="kt">VU</span><span class="o">.</span><span class="n">any</span> <span class="p">(</span><span class="nf">\</span><span class="n">n</span> <span class="o">-&gt;</span> <span class="n">isNaN</span> <span class="n">n</span> <span class="o">||</span> <span class="n">isInfinite</span> <span class="n">n</span><span class="p">)</span> <span class="p">(</span><span class="n">toVector</span> <span class="n">res</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="preventing-combinatorial-explosion">Preventing combinatorial explosion</h2>
<p>Deduplication isn’t enough. We need more creative tools to combat the combinatorial explosion as we explore the search space. Most of the ingenuity of enumerative search techniques lies here.</p>

<p>We have a couple of options here:</p>
<ul>
  <li>Greedy: take the <code class="language-plaintext highlighter-rouge">n</code> best expressions each round (otherwise called a beam search)</li>
  <li>Divide-and-conquer: first train a neural network to use as an oracle to guide your search, then run tests against the neural network to attempt to deconstruct the function (this is called the AI Feynman algorithm)</li>
  <li>Evolution: randomly mutate and mix functions to better explore the space of programs (genetic programming)</li>
</ul>

<p>For simplicity, we’ll go with the greedy approach. Most symbolic regression tools use genetic programming, but that approach would be too powerful for our problem.</p>

<p>The greedy approach entails evaluating the expression on some data (which we’ve already done and returned from the <code class="language-plaintext highlighter-rouge">deduplicate</code> function), then picking the best <code class="language-plaintext highlighter-rouge">n</code> functions. We’ll use simple Pearson’s correlation to rank our formulas.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pickTopN</span> <span class="o">::</span>
    <span class="kt">DataFrame</span> <span class="o">-&gt;</span>
    <span class="kt">TypedColumn</span> <span class="kt">Double</span> <span class="o">-&gt;</span>
    <span class="kt">BeamConfig</span> <span class="o">-&gt;</span>
 <span class="p">[(</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">,</span> <span class="kt">TypedColumn</span> <span class="n">a</span><span class="p">)]</span> <span class="o">-&gt;</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span>
<span class="n">pickTopN</span> <span class="kr">_</span> <span class="kr">_</span> <span class="kr">_</span> <span class="kt">[]</span> <span class="o">=</span> <span class="kt">[]</span>
<span class="n">pickTopN</span> <span class="n">df</span> <span class="p">(</span><span class="kt">TColumn</span> <span class="n">col</span><span class="p">)</span> <span class="n">cfg</span> <span class="n">ps</span> <span class="o">=</span>
    <span class="kr">let</span>
 <span class="n">l</span> <span class="o">=</span> <span class="n">toVector</span> <span class="o">@</span><span class="kt">Double</span> <span class="n">col</span>
 <span class="n">ordered</span> <span class="o">=</span>
            <span class="kt">Prelude</span><span class="o">.</span><span class="n">take</span>
 <span class="p">(</span><span class="n">beamLength</span> <span class="n">cfg</span><span class="p">)</span> <span class="c1">-- 4. Take only the first n</span>
                <span class="kt">L</span><span class="o">.</span><span class="n">sortBy</span>
 <span class="p">((</span><span class="n">flip</span> <span class="n">compare</span><span class="p">)</span> <span class="p">`</span><span class="n">on</span><span class="p">`</span> <span class="n">snd</span><span class="p">)</span> <span class="c1">-- 3. Sort functions by descending objective.</span>
 <span class="p">(</span><span class="n">map</span>
 <span class="p">(</span><span class="nf">\</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">res</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">getLossFunction</span> <span class="p">(</span><span class="n">lossFunction</span> <span class="n">cfg</span><span class="p">)</span> <span class="n">l</span> <span class="p">(</span><span class="n">asDoubleVector</span> <span class="n">res</span><span class="p">)))</span>  <span class="c1">-- 2. Compute objective</span>
 <span class="n">ps</span> <span class="c1">-- 1. programs</span>
 <span class="p">)</span>
     <span class="kr">in</span> <span class="n">map</span> <span class="n">fst</span> <span class="n">ordered</span>
</code></pre></div></div>

<p>Now we have everything we need to define our beam search:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">beamSearch</span> <span class="o">::</span>
    <span class="kt">DataFrame</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Parameters of the beam search.</span>
    <span class="kt">BeamConfig</span> <span class="o">-&gt;</span>
 <span class="c1">-- | example labels</span>
    <span class="kt">TypedColumn</span> <span class="kt">Double</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Constants</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Conditions</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Bool</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Programs</span>
 <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span>
    <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">)</span>
<span class="n">beamSearch</span> <span class="n">df</span> <span class="n">cfg</span> <span class="n">labels</span> <span class="n">constants</span> <span class="n">conds</span> <span class="n">programs</span>
 <span class="o">|</span> <span class="n">searchDepth</span> <span class="n">cfg</span> <span class="o">==</span> <span class="mi">0</span> <span class="o">=</span> <span class="kr">case</span> <span class="n">ps</span> <span class="kr">of</span>
        <span class="kt">[]</span> <span class="o">-&gt;</span> <span class="kt">Nothing</span>
 <span class="p">(</span><span class="n">x</span> <span class="o">:</span> <span class="kr">_</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Just</span> <span class="n">x</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span>
 <span class="n">beamSearch</span>
 <span class="n">df</span>
 <span class="p">(</span><span class="n">cfg</span><span class="p">{</span><span class="n">searchDepth</span> <span class="o">=</span> <span class="n">searchDepth</span> <span class="n">cfg</span> <span class="o">-</span> <span class="mi">1</span><span class="p">})</span>
 <span class="n">labels</span>
 <span class="n">constants</span>
 <span class="p">(</span><span class="n">generatePrograms</span> <span class="n">vars</span> <span class="n">constants</span> <span class="n">ps</span><span class="p">)</span>
  <span class="kr">where</span>
 <span class="n">vars</span> <span class="o">=</span> <span class="n">map</span> <span class="n">col</span> <span class="n">names</span>
 <span class="n">ps</span> <span class="o">=</span> <span class="n">pickTopN</span> <span class="n">df</span> <span class="n">labels</span> <span class="n">cfg</span> <span class="o">$</span> <span class="n">deduplicate</span> <span class="n">df</span> <span class="n">programs</span>
 <span class="n">names</span> <span class="o">=</span> <span class="kt">D</span><span class="o">.</span><span class="n">columnNames</span> <span class="n">df</span>
</code></pre></div></div>

<h2 id="retraining-iris">Retraining iris</h2>

<p>We create a training split to use for our feature engineering. Doing feature engineering on a training set avoids leaking test-time information.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>import qualified DataFrame.Hasktorch as DHT
import qualified System.Random as SysRand
import DataFrame.Functions ((.==))

let (training, _) = D.randomSplit (SysRand.mkStdGen 12345) 0.7 df
</code></pre></div></div>

<p>We then create a separate feature for each class by setting its instances to 1 and everything else to 0.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">labelWith</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="kt">DataFrame</span> <span class="o">-&gt;</span> <span class="kt">DataFrame</span>
<span class="n">labelWith</span> <span class="n">species</span> <span class="o">=</span> <span class="n">training</span> <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"variety"</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">ifThenElse</span> <span class="p">(</span><span class="n">variety</span> <span class="o">.==</span> <span class="n">species</span><span class="p">)</span> <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">lit</span> <span class="o">@</span><span class="kt">Double</span> <span class="mi">1</span><span class="p">)</span> <span class="mi">0</span><span class="p">)</span>

<span class="n">versicolor</span> <span class="o">=</span> <span class="kt">F</span><span class="o">.</span><span class="n">synthesizeFeatureExpr</span> <span class="s">"variety"</span> <span class="kt">F</span><span class="o">.</span><span class="n">defaultBeamConfig</span> <span class="p">(</span><span class="n">labelWith</span> <span class="s">"Versicolor"</span><span class="p">)</span>
<span class="n">virginica</span> <span class="o">=</span> <span class="kt">F</span><span class="o">.</span><span class="n">synthesizeFeatureExpr</span> <span class="s">"variety"</span> <span class="kt">F</span><span class="o">.</span><span class="n">defaultBeamConfig</span> <span class="p">(</span><span class="n">labelWith</span> <span class="s">"Virginica"</span><span class="p">)</span>
<span class="n">setosa</span> <span class="o">=</span> <span class="kt">F</span><span class="o">.</span><span class="n">synthesizeFeatureExpr</span> <span class="s">"variety"</span> <span class="kt">F</span><span class="o">.</span><span class="n">defaultBeamConfig</span> <span class="p">(</span><span class="n">labelWith</span> <span class="s">"Setosa"</span><span class="p">)</span>
</code></pre></div></div>

<p>Running our synthesizeFeatureExpr function gives us the following features:</p>
<ul>
  <li>virginica: <code class="language-plaintext highlighter-rouge">(col @Double "petal.length") * (col @Double "petal.width")</code></li>
  <li>versicolor: <code class="language-plaintext highlighter-rouge">cos (col @Double "petal.length")</code></li>
  <li>setosa: <code class="language-plaintext highlighter-rouge">1 / (col @Double "petal.length")</code></li>
</ul>

<p>Let’s look through each of these features one by one.</p>

<h3 id="virginica">Virginica</h3>
<p>This feature calculates the approximate petal area. Virginica irises have the largest petals, so this product yields the highest values for this species. For example, if virginica has petals ~6cm long and ~2cm wide, this gives ~12, while smaller-petaled species produce smaller values.</p>

<h3 id="versicolor">Versicolor</h3>

<p>This feature applies a cosine transformation to petal length. This is the most clever/unusual one. Since:</p>

<ul>
  <li>Setosa has short petals (~1.0-1.9 cm)</li>
  <li>Versicolor has medium petals (~3.0-5.1 cm)</li>
  <li>Virginica has long petals (~4.5-6.9 cm)</li>
</ul>

<p>The cosine function’s oscillating nature means that versicolor’s petal lengths (around 3-5 radians) fall into a specific range of the cosine curve that differs from the other species. This creates separation even though versicolor is “in the middle” on raw measurements.</p>

<p>This suggests that the search was trying to model some non-linearity which could be approximated by <code class="language-plaintext highlighter-rouge">cos</code>.</p>

<h3 id="setosa">Setosa</h3>
<p>This feature creates an inverse relationship with petal length. Since setosa has the shortest petals (~1-2 cm), dividing 1 by small numbers yields large values (e.g., 1/1.5 ≈ 0.66). Longer-petaled species get much smaller values (e.g., 1/6.0 ≈ 0.16).</p>

<h2 id="initial-results">Initial results</h2>
<p>What happens when we feed these features into the neural network? Well, we get better at the training set:</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>0.969697</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>1.0</td>
      <td>0.9722222</td>
    </tr>
  </tbody>
</table>

<p>But the test set is a mixed bag.</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>1.0</td>
      <td>0.8333333</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>0.8235294</td>
      <td>1.0</td>
    </tr>
  </tbody>
</table>

<p>We do better at classifying setosas, but we still have trouble differentiating between versicolor and virginica. The <code class="language-plaintext highlighter-rouge">cos</code> must have overfit to the non-linear pattern in the training set but failed to generalise. How can we better model the separation between versicolor and virginica?</p>

<p>Let’s throw some conditionals into our DSL and see how far that gets us.</p>

<h2 id="conditionals-are-your-frenemy">Conditionals are your frenemy</h2>
<p>Conditionals model many valuable programs. Armed with conditionals, we would be able to express almost any function. That’s a good thing, but it also means we can even express the meaningless functions that arbitrarily overfit our data. Conditionals also mean our search space blows up. Take a simple if-statement that always checks <code class="language-plaintext highlighter-rouge">&lt;=</code> in the condition. <code class="language-plaintext highlighter-rouge">if p &lt;= q then r else s</code>. Computing this for all existing programs does O(n<sup>4</sup>) at each tree level. This brings our search to a grinding halt at just the second level, even with a small dataset and feature space.</p>

<p>How can we make conditionals tractable? Well, we can run a beam search over just the conditionals, combining them to form conditions that are strongly related to the target variable. Because we want to capture interesting non-linearities, conditionals are evaluated, converted to 0 or 1, then ranked by mutual information rather than Pearson correlation.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">generateConditions</span> <span class="o">::</span>
    <span class="kt">TypedColumn</span> <span class="kt">Double</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Bool</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Double</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="kt">DataFrame</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Expr</span> <span class="kt">Bool</span><span class="p">]</span>
<span class="n">generateConditions</span> <span class="n">labels</span> <span class="n">conds</span> <span class="n">ps</span> <span class="n">df</span> <span class="o">=</span>
    <span class="kr">let</span>
 <span class="n">newConds</span> <span class="o">=</span>
 <span class="p">[</span> <span class="n">p</span> <span class="o">.&lt;=</span> <span class="n">q</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">ps</span>
 <span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">ps</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="kt">DataFrame</span><span class="o">.</span><span class="kt">Functions</span><span class="o">.</span><span class="n">not</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">conds</span>
 <span class="p">]</span>
 <span class="n">expandedConds</span> <span class="o">=</span>
 <span class="n">conds</span>
 <span class="o">++</span> <span class="n">newConds</span>
 <span class="o">++</span> <span class="p">[</span><span class="n">p</span> <span class="o">.&amp;&amp;</span> <span class="n">q</span> <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">newConds</span><span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">conds</span><span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span><span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span><span class="n">p</span> <span class="o">.||</span> <span class="n">q</span> <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">newConds</span><span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">conds</span><span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span><span class="p">]</span>
     <span class="kr">in</span>
 <span class="n">pickTopN</span> <span class="mi">10</span> <span class="n">df</span> <span class="n">labels</span> <span class="p">(</span><span class="n">deduplicate</span> <span class="n">df</span> <span class="n">expandedConds</span><span class="p">)</span>
</code></pre></div></div>

<p>Now, our program supports two expansions—one for conditionals and another for programs.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">generatePrograms</span> <span class="kr">_</span> <span class="kr">_</span> <span class="n">vars'</span> <span class="n">constants</span> <span class="kt">[]</span> <span class="o">=</span> <span class="n">vars'</span> <span class="o">++</span> <span class="n">constants</span>
<span class="n">generatePrograms</span> <span class="n">includeConds</span> <span class="n">conds</span> <span class="n">vars</span> <span class="n">constants</span> <span class="n">ps</span> <span class="o">=</span>
    <span class="kr">let</span>
 <span class="n">existingPrograms</span> <span class="o">=</span> <span class="n">ps</span> <span class="o">++</span> <span class="n">vars</span> <span class="o">++</span> <span class="n">constants</span>
     <span class="kr">in</span>
 <span class="o">...</span>
 <span class="o">++</span> <span class="p">(</span> <span class="kr">if</span> <span class="n">includeConds</span>
                    <span class="kr">then</span>
 <span class="p">[</span> <span class="kt">DataFrame</span><span class="o">.</span><span class="kt">Functions</span><span class="o">.</span><span class="n">min</span> <span class="n">p</span> <span class="n">q</span>
 <span class="o">|</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="kt">Prelude</span><span class="o">.</span><span class="n">not</span> <span class="p">(</span><span class="n">isLiteral</span> <span class="n">p</span> <span class="o">&amp;&amp;</span> <span class="n">isLiteral</span> <span class="n">q</span><span class="p">)</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="n">j</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="kt">DataFrame</span><span class="o">.</span><span class="kt">Functions</span><span class="o">.</span><span class="n">max</span> <span class="n">p</span> <span class="n">q</span>
 <span class="o">|</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">zip</span> <span class="p">[</span><span class="mi">0</span> <span class="o">..</span><span class="p">]</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="kt">Prelude</span><span class="o">.</span><span class="n">not</span> <span class="p">(</span><span class="n">isLiteral</span> <span class="n">p</span> <span class="o">&amp;&amp;</span> <span class="n">isLiteral</span> <span class="n">q</span><span class="p">)</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">/=</span> <span class="n">q</span>
 <span class="p">,</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="n">j</span>
 <span class="p">]</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">ifThenElse</span> <span class="n">cond</span> <span class="n">r</span> <span class="n">s</span>
 <span class="o">|</span> <span class="n">cond</span> <span class="o">&lt;-</span> <span class="n">conds</span>
 <span class="p">,</span> <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">s</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">r</span> <span class="o">/=</span> <span class="n">s</span>
 <span class="p">]</span>
                    <span class="kr">else</span> <span class="kt">[]</span>
 <span class="p">)</span>
</code></pre></div></div>

<h3 id="generated-features">Generated features</h3>
<p>Running our synthesizeFeatureExpr function with conditionals enabled gives us the following features:</p>
<ul>
  <li>virginica: <code class="language-plaintext highlighter-rouge">ifThenElse (or (leq (col @Double "petal.length") (col @Double "sepal.width")) (leq (lit (1.8)) (col @Double "petal.width"))) (lit (7.7)) (col @Double "petal.length")</code></li>
  <li>versicolor: <code class="language-plaintext highlighter-rouge">ifThenElse (or (leq (lit (5.1)) (col @Double "petal.length")) (leq (lit (1.8)) (col @Double "petal.width"))) (lit 1) (lit 0)</code></li>
  <li>setosa: <code class="language-plaintext highlighter-rouge">ifThenElse (leq (col @Double "sepal.width") (col @Double "petal.length")) (lit 0) (lit 1)</code></li>
</ul>

<h3 id="virginica-1">Virginica</h3>
<p>This feature creates a derived feature that:</p>

<ul>
  <li>Returns a constant value of 7.7 when either the petal is unusually short compared to sepal width, OR the petal width is ≥ 1.8 (a wide petal)</li>
  <li>Otherwise returns the actual petal length</li>
  <li>This likely captures virginica characteristics (larger petals)</li>
</ul>

<h3 id="versicolor-1">Versicolor</h3>
<p>This feature is a binary indicator that flags:</p>

<ul>
  <li>Flowers with petal length ≥ 5.1 OR petal width ≥ 1.8</li>
  <li>Returns 1 (true) or 0 (false)</li>
  <li>This targets medium-sized flowers typical of versicolor</li>
</ul>

<h3 id="setosa-1">Setosa</h3>
<p>This feature is a binary indicator that returns 1 when:</p>

<ul>
  <li>Sepal width &lt;= petal length</li>
  <li>Setosas typically have short petals relative to sepal width, so this would return 1 for setosas and 0 for others.</li>
</ul>

<h3 id="final-results">Final results</h3>
<p>With these 3 features, we get almost everything right on the training set.</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>0.9411765</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>1.0</td>
      <td>0.9444444</td>
    </tr>
  </tbody>
</table>

<p>Our performance on the test set has improved and is much more balanced.</p>

<table>
  <thead>
    <tr>
      <th>variety</th>
      <th>precision</th>
      <th>recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setosa</td>
      <td>1.0</td>
      <td>1.0</td>
    </tr>
    <tr>
      <td>Versicolor</td>
      <td>0.9444444</td>
      <td>0.9444444</td>
    </tr>
    <tr>
      <td>Virginica</td>
      <td>0.9285714</td>
      <td>0.9285714</td>
    </tr>
  </tbody>
</table>

<p>This is a clear improvement over both the previous versions.</p>

<p>We did it! We used interpretable, automatically generated features to improve our neural network!</p>

<h2 id="the-symbolic-regression-landscape">The symbolic regression landscape</h2>

<p>Many battle-tested tools employ the techniques described here. <a href="https://github.com/MilesCranmer/PySR">PySr</a> is the most mature in the Julia/Python ecosystem. Haskell has <a href="github.com/folivetti/srtree#readme">SRTree</a> (which we plan to use eventually in the dataframe library).</p>

<p>Wanna learn more about bottom-up synthesis? MIT offers an introduction to program synthesis, with a problem set that roughly covers what we’ve done in these last two posts. In fact, I encourage you to audit the class to keep abreast with what’s happening in the neuro-symbolic world.</p>

<h2 id="whats-next">What’s next?</h2>
<p>Conditionals were a powerful tool, but posed a big challenge for us. How do we use them without blowing up the search space? What if we could tell the search the shape of the final program (that it’s a conditional with 2 or 3 terms, and it returns 1 or 0)? We would only search for the hole we need to fill, not worry about others searching everything else.</p>

<p>Top-down enumerative search will be the subject of our next post in the series.</p>

<h2 id="wanna-join-in-this-work">Wanna join in this work?</h2>

<p>Come over to the <a href="https://discord.gg/8u8SCWfrNC">dataHaskell Discord</a> where we discuss the work that is at the intersection of functional programming and data science?</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Introduction This post kicks off the second part of a hands-on series about program synthesis. We’ll apply the previously explored technique (an enumerative bottom-up search) to a slightly more realistic problem: automatically generating features for the Iris dataset.]]></summary></entry><entry><title type="html">Progress towards Kaggle-style workflows in Haskell</title><link href="mchav.github.io/iris-classification-in-haskell/" rel="alternate" type="text/html" title="Progress towards Kaggle-style workflows in Haskell" /><published>2025-10-13T00:00:00+00:00</published><updated>2025-10-13T00:00:00+00:00</updated><id>mchav.github.io/iris-classification-in-haskell</id><content type="html" xml:base="mchav.github.io/iris-classification-in-haskell/"><![CDATA[<p>There’s been a lot of work in the Haskell ecosystem that has made it easier to write interactive Kaggle-like scripts. I’d like to showcase the synergy between 3 such tools: <a href="https://github.com/mchav/dataframe">dataframe</a> (my own creation), <a href="github.com/hasktorch/hasktorch">hasktorch</a>, and <a href="github.com/IHaskell/IHaskell">IHaskell</a>.</p>

<p>At a high level the intent of this post is to:</p>
<ul>
  <li>get people excited about how far the ecosystem has come,</li>
  <li>checkpoint what the ecosystem currently looks like,</li>
  <li>identify areas of improvement in the workflow.</li>
</ul>

<p>This post is a narrative companion to a <a href="https://ulwazi-exh9dbh2exbzgbc9.westus-01.azurewebsites.net/doc/tree/Iris.ipynb">hosted notebook</a>, but with a little more context.</p>

<p>This work was done as part of the <a href="https://www.datahaskell.org/">dataHaskell</a>: an organization devoted to enabling reliable and reproducible machine learning using the Haskell programming language.</p>

<p>Special thanks to Jireh Tan for writing the code.</p>

<h2 id="the-iris-classification-problem">The iris classification problem</h2>

<p>The Iris dataset is machine learning’s “Hello, World!” created by statistician Ronald Fisher in 1936. The dataset contains measurements of 150 iris flowers from three species:</p>

<ul>
  <li>Setosa</li>
  <li>Versicolor</li>
  <li>Virginica</li>
</ul>

<p>For each flower, we have four measurements (in centimetres):</p>
<ul>
  <li>Sepal length and width (the green outer part)</li>
  <li>Petal length and width (the colourful inner part)</li>
</ul>

<p>Our task is to predict the species of a flower given these four measurements.</p>

<h2 id="loading-and-exploring-the-data">Loading and Exploring the Data</h2>
<p>We start by loading the data from a Parquet file using the DataFrame library:</p>

<p><img src="/images/iris/loading_data.png" alt="Loading the data into a dataframe" title="Loading Iris data" /></p>

<p>One immediate advantage of Haskell: the type system tells us we have no missing values. If any column contained nulls we’d see a <code class="language-plaintext highlighter-rouge">Maybe</code> prefix in front of that type. At least in this case, we don’t have to worry about deciding between imputing data and dropping it.</p>

<h2 id="exploring-our-data">Exploring our data</h2>

<h3 id="checking-for-balance">Checking for balance</h3>
<p>Before building any model, we need to know if our dataset is balanced. A severely imbalanced dataset (say, 140 Setosas and 5 each of the others) would make accurate prediction nearly impossible.</p>

<p><img src="/images/iris/class_pie_chart.png" alt="Class distribution pie chart" title="Class pie chart" /></p>

<p>Great! A perfect balance with 50 flowers of each species. This means we don’t have to spend time on rebalancing tricks.</p>

<h3 id="some-light-analysis">Some light analysis</h3>

<p>Next, we compare feature magnitudes per class. We unintuitively do this in a stacked bar chart. But since our plot is interactive and allows us to exclude features at the click of a button we throw them all in there then click around to look at their individual differences.</p>

<p><img src="/images/iris/feature_distribution.png" alt="Feature distribution" title="Feature distibution" /></p>

<p>Our charts broadly suggest that <code class="language-plaintext highlighter-rouge">Virginica &gt; Versicolor &gt; Setosa</code> for most features, with one fun exception: Setosa has the largest sepal width.</p>

<p>To get a sense of the spread of the features we can do box plots per variety and compare them:</p>

<p><img src="/images/iris/feature_box_plots.png" alt="Box plots of features" title="Box plots of features" /></p>

<p>Since we don’t have box plots implemented as web plots we fall back to <a href="github.com/mchav/granite">granite’s ASCII plot</a>.</p>

<p>For Setosa:</p>

<p><img src="/images/iris/setosa_box_plot.png" alt="Setosa box plot" title="Setosa box plot" /></p>

<p>For Versicolor</p>

<p><img src="/images/iris/versicolor_box_plot.png" alt="Versicolor box plot" title="Versicolor box plot" /></p>

<p>For Virginica:</p>

<p><img src="/images/iris/virginica_box_plot.png" alt="Virginica box plot" title="Virginica box plot" /></p>

<p>The box plots reveal that Setosas should be easy to identify—their petals are tiny with little variance. Versicolors and Virginicas overlap more, but we can still try to distinguish them using feature engineering.</p>

<h3 id="creating-new-features">Creating new features</h3>

<p>From our exploration we have 5 promising feature candidates:</p>

<ul>
  <li>sepal area</li>
  <li>sepal length to width ratio</li>
  <li>petal area</li>
  <li>petal length to width ratio</li>
  <li>sepal area / petal area</li>
</ul>

<p>To derive these feature it’ll help to create typed references to the columns using template Haskell. This is the equivalent of doing a database migration against a schema definition. Except in this case we just freeze the types as they exist in the dataframe already.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">:</span><span class="n">set</span> <span class="o">-</span><span class="kt">XTemplateHaskell</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame.Functions</span> <span class="k">as</span> <span class="n">F</span>

<span class="kt">F</span><span class="o">.</span><span class="n">declareColumns</span> <span class="n">df</span>
</code></pre></div></div>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="nn">DataFrame</span> <span class="p">((</span><span class="o">|&gt;</span><span class="p">))</span>

<span class="n">engineered</span> <span class="o">=</span> <span class="n">df</span>
               <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"sepal_area"</span> <span class="p">(</span><span class="n">sepal_length</span> <span class="o">*</span> <span class="n">sepal_width</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"sepal_ratio"</span> <span class="p">(</span><span class="n">sepal_length</span> <span class="o">/</span> <span class="n">sepal_width</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"petal_area"</span> <span class="p">(</span><span class="n">petal_length</span> <span class="o">*</span> <span class="n">petal_width</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"petal_ratio"</span> <span class="p">(</span><span class="n">petal_length</span> <span class="o">/</span> <span class="n">petal_width</span><span class="p">)</span>
               <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span> <span class="s">"area_ratio"</span> <span class="p">((</span><span class="n">sepal_length</span> <span class="o">*</span> <span class="n">sepal_width</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">petal_length</span> <span class="o">*</span> <span class="n">petal_width</span><span class="p">))</span>
</code></pre></div></div>

<p>After repeating the previous diagramming (see the full notebook) we decide we can get by with three features:</p>
<ul>
  <li>petal_area,</li>
  <li>sepal_area, and,</li>
  <li>area_ratio</li>
</ul>

<h4 id="creating-a-custom-data-type-for-our-label">Creating a custom data type for our label</h4>
<p>Here’s where Haskell’s type system shines. Instead of treating species as magic integers, we define them explicitly:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Iris</span>
    <span class="o">=</span> <span class="kt">Setosa</span>
    <span class="o">|</span> <span class="kt">Versicolor</span>
    <span class="o">|</span> <span class="kt">Virginica</span>
    <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">,</span> <span class="kt">Read</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">,</span> <span class="kt">Enum</span><span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">deriving</code> clause automatically generates useful functions:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Eq</code>: Allows us to compare Iris values for equality</li>
  <li><code class="language-plaintext highlighter-rouge">Show</code>: Converts Iris to a String (e.g., “Setosa”)</li>
  <li><code class="language-plaintext highlighter-rouge">Read</code>: Converts a String to Iris (e.g., “Setosa” → Setosa)</li>
  <li><code class="language-plaintext highlighter-rouge">Ord</code>: Allows ordering/sorting</li>
  <li><code class="language-plaintext highlighter-rouge">Enum</code>: Lets us convert to/from integers (Setosa=0, Versicolor=1, Virginica=2)</li>
</ul>

<p>We convert our string labels to this type:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Text</span> <span class="k">as</span> <span class="n">T</span>

<span class="n">modellingDf</span> <span class="o">=</span> <span class="kt">D</span><span class="o">.</span><span class="n">select</span> <span class="p">[</span><span class="s">"petal_area"</span><span class="p">,</span> <span class="s">"sepal_area"</span><span class="p">,</span> <span class="s">"area_ratio"</span><span class="p">,</span> <span class="s">"variety"</span><span class="p">]</span> <span class="n">engineered</span>
<span class="n">withTypedLabel</span> <span class="o">=</span>
        <span class="n">modellingDf</span>
            <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">derive</span>
                <span class="s">"variety"</span>
                <span class="p">(</span><span class="kt">F</span><span class="o">.</span><span class="n">lift</span> <span class="p">(</span><span class="n">fromEnum</span> <span class="o">.</span> <span class="n">read</span> <span class="o">@</span><span class="kt">Iris</span> <span class="o">.</span> <span class="kt">T</span><span class="o">.</span><span class="n">unpack</span><span class="p">)</span> <span class="n">variety</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="preparing-our-data-for-ml">Preparing our data for ML</h2>

<p>For our model to generalise, we split the data: 70% for training, 30% for testing. Our random split function takes a seed and probability <code class="language-plaintext highlighter-rouge">p</code>. It return a tuple where <code class="language-plaintext highlighter-rouge">(p * 100)</code> % of the vakues are in the first element and the rest are in the second.</p>

<p>The random seed (42) ensures reproducibility which is crucial for debugging.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="k">qualified</span> <span class="nn">DataFrame.Hasktorch</span> <span class="k">as</span> <span class="n">DHT</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">System.Random</span> <span class="k">as</span> <span class="n">SysRand</span>
<span class="kr">import</span> <span class="nn">Control.Exception</span> <span class="p">(</span><span class="nf">throw</span><span class="p">)</span>

<span class="kr">let</span> <span class="p">(</span><span class="n">trainDf</span><span class="p">,</span> <span class="n">testDf</span><span class="p">)</span> <span class="o">=</span> <span class="kt">D</span><span class="o">.</span><span class="n">randomSplit</span> <span class="p">(</span><span class="kt">SysRand</span><span class="o">.</span><span class="n">mkStdGen</span> <span class="mi">42</span><span class="p">)</span> <span class="mf">0.7</span> <span class="n">withTypedLabel</span>

<span class="kr">let</span> <span class="n">trainFeaturesTr</span> <span class="o">=</span>
        <span class="n">trainDf</span>
            <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">exclude</span> <span class="p">[</span><span class="s">"variety"</span><span class="p">]</span>
            <span class="o">|&gt;</span> <span class="kt">DHT</span><span class="o">.</span><span class="n">toTensor</span>
<span class="kr">let</span> <span class="n">testFeaturesTr</span> <span class="o">=</span>
        <span class="n">testDf</span>
            <span class="o">|&gt;</span> <span class="kt">D</span><span class="o">.</span><span class="n">exclude</span> <span class="p">[</span><span class="s">"variety"</span><span class="p">]</span>
            <span class="o">|&gt;</span> <span class="kt">DHT</span><span class="o">.</span><span class="n">toTensor</span>

<span class="kr">let</span> <span class="n">trainLabels</span> <span class="o">=</span> <span class="n">either</span> <span class="n">throw</span> <span class="n">id</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">columnAsIntVector</span> <span class="s">"variety"</span> <span class="n">trainDf</span><span class="p">)</span>
<span class="kr">let</span> <span class="n">testLabels</span> <span class="o">=</span> <span class="n">either</span> <span class="n">throw</span> <span class="n">id</span> <span class="p">(</span><span class="kt">D</span><span class="o">.</span><span class="n">columnAsIntVector</span> <span class="s">"variety"</span> <span class="n">testDf</span><span class="p">)</span>
</code></pre></div></div>

<p>Since we are predicting one of many classes, our target should be a one-hot vector:</p>

<ul>
  <li>0 (Setosa) → [1.0, 0.0, 0.0]</li>
  <li>1 (Versicolor) → [0.0, 1.0, 0.0]</li>
  <li>2 (Virginica) → [0.0, 0.0, 1.0]</li>
</ul>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Torch</span> <span class="k">as</span> <span class="n">HT</span>

<span class="kr">let</span> <span class="n">trainLabelsTr</span> <span class="o">=</span> <span class="kt">HT</span><span class="o">.</span><span class="n">toType</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Float</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="n">oneHot</span> <span class="mi">3</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="n">asTensor</span> <span class="n">trainLabels</span><span class="p">))</span>
<span class="kr">let</span> <span class="n">testLabelsTr</span> <span class="o">=</span> <span class="kt">HT</span><span class="o">.</span><span class="n">toType</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Float</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="n">oneHot</span> <span class="mi">3</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="n">asTensor</span> <span class="o">$</span> <span class="n">testLabels</span><span class="p">))</span>
</code></pre></div></div>

<p>The function application can be read from right to left:</p>
<ul>
  <li>Make the label into a tensor</li>
  <li>Create a one-hot vector with three entries.</li>
  <li>Then finally convert each int to a float.</li>
</ul>

<p>That’s it. Our data is ready for machine learning. Now we can pass it to <code class="language-plaintext highlighter-rouge">Torch</code> and use it to train a small multi-layer perceptron.</p>

<h2 id="training-the-model">Training the model</h2>

<h3 id="neural-network-architecture">Neural Network Architecture</h3>

<p>We define our Multi-Layer Perceptron (MLP) architecture in two parts.</p>

<p>First, a specification that describes the shape of our network. Second, the actual model with its layers. Each layer is a <code class="language-plaintext highlighter-rouge">Linear</code> transformation (like <code class="language-plaintext highlighter-rouge">nn.Linear</code> in PyTorch).</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">:</span><span class="n">set</span> <span class="o">-</span><span class="kt">XDeriveGeneric</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Torch</span> <span class="k">as</span> <span class="n">HT</span>

<span class="kr">import</span> <span class="nn">GHC.Generics</span> <span class="p">(</span><span class="kt">Generic</span><span class="p">)</span>

<span class="kr">data</span> <span class="kt">MLPSpec</span> <span class="o">=</span> <span class="kt">MLPSpec</span>
    <span class="p">{</span> <span class="n">inputFeatures</span> <span class="o">::</span> <span class="kt">Int</span>   <span class="c1">-- Number of input features (3 for our dataset)</span>
    <span class="p">,</span> <span class="n">hiddenFeatures</span> <span class="o">::</span> <span class="kt">Int</span>  <span class="c1">-- Number of neurons in hidden layer</span>
    <span class="p">,</span> <span class="n">outputFeatures</span> <span class="o">::</span> <span class="kt">Int</span>  <span class="c1">-- Number of output classes (3 species)</span>
    <span class="p">}</span>
    <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Show</span><span class="p">,</span> <span class="kt">Eq</span><span class="p">)</span>

<span class="kr">data</span> <span class="kt">MLP</span> <span class="o">=</span> <span class="kt">MLP</span>
    <span class="p">{</span> <span class="n">l0</span> <span class="o">::</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Linear</span>  <span class="c1">-- Input → Hidden layer</span>
    <span class="p">,</span> <span class="n">l1</span> <span class="o">::</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Linear</span>  <span class="c1">-- Hidden → Output layer</span>
    <span class="p">}</span>
    <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Generic</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p>Network Architecture Diagram:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Input Layer (3)  →  Hidden Layer (8)  →  Output Layer (3)
---------------     -----------------     ----------------
sepal_area          ReLU activation       Softmax
petal_area          (introduces           (produces
area_ratio          non-linearity)        probabilities)
                                          Setosa
                                          Versicolor
                                          Virginica
</code></pre></div></div>

<h3 id="making-our-model-trainable">Making Our Model Trainable</h3>

<p>We need to tell Hasktorch how to initialise our network with random weights.
This is similar to defining <code class="language-plaintext highlighter-rouge">__init__()</code> in a PyTorch <code class="language-plaintext highlighter-rouge">nn.Module</code>:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">:</span><span class="n">set</span> <span class="o">-</span><span class="kt">XRecordWildCards</span>

<span class="kr">instance</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Parameterized</span> <span class="kt">MLP</span>
<span class="kr">instance</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Randomizable</span> <span class="kt">MLPSpec</span> <span class="kt">MLP</span> <span class="kr">where</span>
    <span class="n">sample</span> <span class="kt">MLPSpec</span><span class="p">{</span><span class="o">..</span><span class="p">}</span> <span class="o">=</span>
        <span class="kt">MLP</span>
            <span class="o">&lt;$&gt;</span> <span class="kt">HT</span><span class="o">.</span><span class="n">sample</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="kt">LinearSpec</span> <span class="n">inputFeatures</span> <span class="n">hiddenFeatures</span><span class="p">)</span>
            <span class="o">&lt;*&gt;</span> <span class="kt">HT</span><span class="o">.</span><span class="n">sample</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="kt">LinearSpec</span> <span class="n">hiddenFeatures</span> <span class="n">outputFeatures</span><span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">&lt;$&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;*&gt;</code> operators are Haskell’s way of working with random
initialisation. Think of this as: “Create an MLP by randomly sampling
weights for both layers.”</p>

<h3 id="forward-pass">Forward Pass</h3>

<p>This function defines how data flows through the network. It’s equivalent
to the <code class="language-plaintext highlighter-rouge">forward()</code> method in PyTorch. Read it from right to left (or
bottom to top in the chain):</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mlp</span> <span class="o">::</span> <span class="kt">MLP</span> <span class="o">-&gt;</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span> <span class="o">-&gt;</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span>
<span class="n">mlp</span> <span class="kt">MLP</span><span class="p">{</span><span class="o">..</span><span class="p">}</span> <span class="o">=</span>
    <span class="kt">HT</span><span class="o">.</span><span class="n">softmax</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="kt">Dim</span> <span class="mi">1</span><span class="p">)</span>       <span class="c1">-- 4. Apply softmax (probabilities sum to 1)</span>
        <span class="o">.</span> <span class="kt">HT</span><span class="o">.</span><span class="n">linear</span> <span class="n">l1</span>          <span class="c1">-- 3. Apply second linear layer</span>
        <span class="o">.</span> <span class="kt">HT</span><span class="o">.</span><span class="n">relu</span>               <span class="c1">-- 2. Apply ReLU activation</span>
        <span class="o">.</span> <span class="kt">HT</span><span class="o">.</span><span class="n">linear</span> <span class="n">l0</span>          <span class="c1">-- 1. Apply first linear layer</span>
</code></pre></div></div>

<p>Read this right-to-left (or bottom-to-top). The <code class="language-plaintext highlighter-rouge">.</code> operator composes functions.</p>

<p>In Python/PyTorch, this would look like:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">):</span>
    <span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">l0</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
    <span class="n">x</span> <span class="o">=</span> <span class="n">F</span><span class="p">.</span><span class="n">relu</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
    <span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">l1</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
    <span class="n">x</span> <span class="o">=</span> <span class="n">F</span><span class="p">.</span><span class="n">softmax</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">x</span>
</code></pre></div></div>

<p>The Haskell version is directly captures that we are composing a series of functions and using the output of one as the input to the other.</p>

<h3 id="training-loop">Training Loop</h3>

<p>Training longer doesn’t always mean better. At some point, your model starts memorizing the training data instead of learning general patterns.</p>

<p>We detect this by watching the test set loss. As long as test loss keeps decreasing, the model is learning generalizable patterns. But if training loss keeps dropping while test loss starts climbing, we’re overfitting.</p>

<p>This is called early stopping, and it’s one of the simplest yet most effective regularisation techniques in machine learning.</p>

<h4 id="the-training-state">The Training State</h4>
<p>Our training loop needs to track more than just the current model. We maintain:</p>

<ul>
  <li>Current model: Updated every iteration</li>
  <li>Best model: The weights that gave us the lowest test loss</li>
  <li>Best loss: What that test loss was</li>
  <li>Patience counter: How many checks since we last improved?</li>
</ul>

<p>If we don’t improve for several consecutive checks (in our case 5 iterations), we give up and return the best model we found.</p>

<p>The loop structure might look unusual if you’re coming from PyTorch. Instead of a for loop with mutation, we use the very similar looking <code class="language-plaintext highlighter-rouge">foldLoop</code> which takes a number of iterations, an initial state and a function that evolves that state with each iteration. Each iteration receives the previous state and returns the new state. No hidden mutable variables, just explicit data flow.</p>

<p>This is eventually the sort of thing we should abstract behind a good machine learning library but for now it’s easy enough to implement.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">import</span> <span class="nn">Control.Monad</span> <span class="p">(</span><span class="nf">when</span><span class="p">)</span>

<span class="n">trainLoop</span> <span class="o">::</span>
    <span class="kt">Int</span> <span class="o">-&gt;</span>                          <span class="c1">-- Number of epochs</span>
    <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span><span class="p">,</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span>       <span class="c1">-- Training features and labels</span>
    <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span><span class="p">,</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span>       <span class="c1">-- Test features and labels</span>
    <span class="kt">MLP</span> <span class="o">-&gt;</span>                          <span class="c1">-- Initial model</span>
    <span class="kt">IO</span> <span class="kt">MLP</span>                          <span class="c1">-- Returns trained model</span>
<span class="n">trainLoop</span> <span class="n">n</span> <span class="n">trainingData</span> <span class="n">testData</span> <span class="n">initialModel</span> <span class="o">=</span> <span class="kr">do</span>
    <span class="kr">let</span> <span class="n">initialState</span> <span class="o">=</span> <span class="n">makeInitialState</span> <span class="n">initialModel</span>
    <span class="p">(</span><span class="kr">_</span><span class="p">,</span> <span class="n">bestModel</span><span class="p">,</span> <span class="kr">_</span><span class="p">,</span> <span class="kr">_</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="kt">HT</span><span class="o">.</span><span class="n">foldLoop</span> <span class="n">initialState</span> <span class="n">n</span> <span class="p">(</span><span class="n">trainingStep</span> <span class="n">trainingData</span> <span class="n">testData</span><span class="p">)</span>
    <span class="n">pure</span> <span class="n">bestModel</span>
  <span class="kr">where</span>
    <span class="c1">-- Configuration constants</span>
    <span class="n">patience</span> <span class="o">=</span> <span class="mi">5</span>
    <span class="n">checkInterval</span> <span class="o">=</span> <span class="mi">500</span>
    <span class="n">learningRate</span> <span class="o">=</span> <span class="mf">1e-2</span>
    <span class="n">initialBestLoss</span> <span class="o">=</span> <span class="n">read</span> <span class="o">@</span><span class="kt">Float</span> <span class="s">"Infinity"</span>
    
    <span class="c1">-- Create initial training state</span>
    <span class="n">makeInitialState</span> <span class="n">model</span> <span class="o">=</span> <span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">model</span><span class="p">,</span> <span class="n">initialBestLoss</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    
    <span class="c1">-- Main training step for each iteration</span>
    <span class="n">trainingStep</span> <span class="p">(</span><span class="n">features</span><span class="p">,</span> <span class="n">labels</span><span class="p">)</span> <span class="p">(</span><span class="n">testFeatures</span><span class="p">,</span> <span class="n">testLabels</span><span class="p">)</span> <span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">bestModel</span><span class="p">,</span> <span class="n">bestLoss</span><span class="p">,</span> <span class="n">counter</span><span class="p">)</span> <span class="n">i</span>
        <span class="o">|</span> <span class="n">shouldStopEarly</span> <span class="n">counter</span> <span class="o">=</span> <span class="n">pure</span> <span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">bestModel</span><span class="p">,</span> <span class="n">bestLoss</span><span class="p">,</span> <span class="n">counter</span><span class="p">)</span>
        <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="kr">do</span>
            <span class="c1">-- Train and evaluate</span>
            <span class="p">(</span><span class="n">updatedModel</span><span class="p">,</span> <span class="n">trainLoss</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="n">performTrainingStep</span> <span class="n">model</span> <span class="n">features</span> <span class="n">labels</span>
            <span class="n">testLoss</span> <span class="o">&lt;-</span> <span class="n">computeTestLoss</span> <span class="n">updatedModel</span> <span class="n">testFeatures</span> <span class="n">testLabels</span>
            
            <span class="c1">-- Log progress periodically</span>
            <span class="n">when</span> <span class="p">(</span><span class="n">shouldLogProgress</span> <span class="n">i</span><span class="p">)</span> <span class="o">$</span>
                <span class="n">logTrainingProgress</span> <span class="n">i</span> <span class="n">trainLoss</span> <span class="n">testLoss</span>
            
            <span class="c1">-- Update best model tracking</span>
            <span class="kr">let</span> <span class="p">(</span><span class="n">newBest</span><span class="p">,</span> <span class="n">newBestLoss</span><span class="p">,</span> <span class="n">newCounter</span><span class="p">)</span> <span class="o">=</span> 
                    <span class="n">updateBestModelTracking</span> <span class="n">updatedModel</span> <span class="n">testLoss</span> <span class="n">bestModel</span> <span class="n">bestLoss</span> <span class="n">counter</span>
            
            <span class="n">pure</span> <span class="p">(</span><span class="n">updatedModel</span><span class="p">,</span> <span class="n">newBest</span><span class="p">,</span> <span class="n">newBestLoss</span><span class="p">,</span> <span class="n">newCounter</span><span class="p">)</span>
    
    <span class="c1">-- Early stopping condition</span>
    <span class="n">shouldStopEarly</span> <span class="n">counter</span> <span class="o">=</span> <span class="n">counter</span> <span class="o">&gt;=</span> <span class="n">patience</span>
    
    <span class="c1">-- Logging condition</span>
    <span class="n">shouldLogProgress</span> <span class="n">i</span> <span class="o">=</span> <span class="n">i</span> <span class="p">`</span><span class="n">mod</span><span class="p">`</span> <span class="n">checkInterval</span> <span class="o">==</span> <span class="mi">0</span>
    
    <span class="c1">-- Perform one training iteration</span>
    <span class="n">performTrainingStep</span> <span class="n">model</span> <span class="n">features</span> <span class="n">labels</span> <span class="o">=</span> <span class="kr">do</span>
        <span class="kr">let</span> <span class="n">predictions</span> <span class="o">=</span> <span class="n">mlp</span> <span class="n">model</span> <span class="n">features</span>
            <span class="n">loss</span> <span class="o">=</span> <span class="kt">HT</span><span class="o">.</span><span class="n">binaryCrossEntropyLoss'</span> <span class="n">labels</span> <span class="n">predictions</span>
        <span class="p">(</span><span class="n">updatedModel</span><span class="p">,</span> <span class="kr">_</span><span class="p">)</span> <span class="o">&lt;-</span> <span class="kt">HT</span><span class="o">.</span><span class="n">runStep</span> <span class="n">model</span> <span class="kt">HT</span><span class="o">.</span><span class="kt">GD</span> <span class="n">loss</span> <span class="n">learningRate</span>
        <span class="n">pure</span> <span class="p">(</span><span class="n">updatedModel</span><span class="p">,</span> <span class="kt">HT</span><span class="o">.</span><span class="n">asValue</span> <span class="n">loss</span> <span class="o">::</span> <span class="kt">Float</span><span class="p">)</span>
    
    <span class="c1">-- Evaluate model on test set</span>
    <span class="n">computeTestLoss</span> <span class="n">model</span> <span class="n">features</span> <span class="n">labels</span> <span class="o">=</span>
        <span class="kr">let</span> <span class="n">predictions</span> <span class="o">=</span> <span class="n">mlp</span> <span class="n">model</span> <span class="n">features</span>
            <span class="n">loss</span> <span class="o">=</span> <span class="kt">HT</span><span class="o">.</span><span class="n">binaryCrossEntropyLoss'</span> <span class="n">labels</span> <span class="n">predictions</span>
        <span class="kr">in</span> <span class="n">pure</span> <span class="p">(</span><span class="kt">HT</span><span class="o">.</span><span class="n">asValue</span> <span class="n">loss</span> <span class="o">::</span> <span class="kt">Float</span><span class="p">)</span>
    
    <span class="c1">-- Print training progress</span>
    <span class="n">logTrainingProgress</span> <span class="n">iteration</span> <span class="n">trainLoss</span> <span class="n">testLoss</span> <span class="o">=</span>
        <span class="n">putStrLn</span> <span class="o">$</span> <span class="s">"Iteration: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">iteration</span>
                <span class="o">++</span> <span class="s">" | Training Set Loss: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">trainLoss</span>
                <span class="o">++</span> <span class="s">" | Test Set Loss: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">testLoss</span>
    
    <span class="c1">-- Update best model if test loss improved, otherwise increment patience counter</span>
    <span class="n">updateBestModelTracking</span> <span class="n">currentModel</span> <span class="n">currentLoss</span> <span class="n">bestModel</span> <span class="n">bestLoss</span> <span class="n">counter</span> <span class="o">=</span>
        <span class="kr">if</span> <span class="n">currentLoss</span> <span class="o">&lt;</span> <span class="n">bestLoss</span>
            <span class="kr">then</span> <span class="p">(</span><span class="n">currentModel</span><span class="p">,</span> <span class="n">currentLoss</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1">-- New best: reset counter</span>
            <span class="kr">else</span> <span class="p">(</span><span class="n">bestModel</span><span class="p">,</span> <span class="n">bestLoss</span><span class="p">,</span> <span class="n">counter</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>  <span class="c1">-- No improvement: increment counter</span>
</code></pre></div></div>

<h3 id="running-the-training-loop">Running the training loop</h3>

<p>We bring everything together by running our training loop with a model intialized from our <code class="language-plaintext highlighter-rouge">MLPSpec</code>.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">initialModel</span> <span class="o">&lt;-</span> <span class="kt">HT</span><span class="o">.</span><span class="n">sample</span> <span class="o">$</span> <span class="kt">MLPSpec</span> <span class="mi">3</span> <span class="mi">8</span> <span class="mi">3</span>

<span class="n">trainedModel</span> <span class="o">&lt;-</span>
    <span class="n">trainLoop</span>
        <span class="mi">10000</span>
        <span class="p">(</span><span class="n">trainFeaturesTr</span><span class="p">,</span> <span class="n">trainLabelsTr</span><span class="p">)</span>
        <span class="p">(</span><span class="n">testFeaturesTr</span><span class="p">,</span> <span class="n">testLabelsTr</span><span class="p">)</span>
        <span class="n">initialModel</span>

<span class="n">putStrLn</span> <span class="s">"Your model weights are given as follows: "</span>
<span class="n">print</span> <span class="n">trainedModel</span>
</code></pre></div></div>

<h2 id="evaluating-the-model">Evaluating the model</h2>
<p>The notebook creates some helps to evaluate to the model. We’ll skip these details since most of the code is vanilla Haskell.</p>

<p>And for our results. Training metrics:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>------------------------------------------
index |  variety   | precision |  recall  
------|------------|-----------|----------
 Int  |    Iris    |   Float   |   Float  
------|------------|-----------|----------
0     | Setosa     | 1.0       | 1.0      
1     | Versicolor | 0.9677419 | 0.9375   
2     | Virginica  | 0.9459459 | 0.9722222
</code></pre></div></div>

<p>Test metrics:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>------------------------------------------
index |  variety   | precision |  recall  
------|------------|-----------|----------
 Int  |    Iris    |   Float   |   Float  
------|------------|-----------|----------
0     | Setosa     | 1.0       | 0.9285714
1     | Versicolor | 0.8888889 | 0.8888889
2     | Virginica  | 0.8666667 | 0.9285714
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>This post demonstrates building a complete multiclass classification system in Haskell from scratch. We tackle the classic Iris dataset. The journey covers data loading, exploratory analysis, feature engineering, typed data modelling, network architecture design, training with early stopping, and evaluation.</p>

<p>The tools for a robust data science journey are scattered in the Haskell ecosystem. The hope is that we can unify them and along the way create ergonomic and safe APIs for data science.</p>

<p>Wanna join in this work?</p>

<p>Come over to the <a href="https://discord.gg/8u8SCWfrNC">dataHaskell Discord</a> to help shape the future of data science and Haskell?</p>

<p>The downloaded ipynb for this post can be found <a href="https://github.com/mchav/ihaskell-dataframe/blob/main/app/Iris.ipynb">here</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[There’s been a lot of work in the Haskell ecosystem that has made it easier to write interactive Kaggle-like scripts. I’d like to showcase the synergy between 3 such tools: dataframe (my own creation), hasktorch, and IHaskell.]]></summary></entry><entry><title type="html">An introduction to program synthesis</title><link href="mchav.github.io/an-introduction-to-program-synthesis/" rel="alternate" type="text/html" title="An introduction to program synthesis" /><published>2025-09-11T00:00:00+00:00</published><updated>2025-09-11T00:00:00+00:00</updated><id>mchav.github.io/an-introduction-to-program-synthesis</id><content type="html" xml:base="mchav.github.io/an-introduction-to-program-synthesis/"><![CDATA[<h2 id="introduction">Introduction</h2>
<p>This post kicks off a hands-on series about program synthesis—the art of teaching machines how to generate code. We’ll build a tiny, <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/popl11-synthesis.pdf">FlashFill-style</a> synthesiser that learns to turn strings like “Joshua Nkomo” into “J. Nkomo” from input/output pairs. We’ll see how to define a tiny string-manipulation language, write an interpreter, and search the space of programs to find one that solves our toy problem.</p>

<p>The accompanying code for this post is <a href="https://github.com/mchav/synthesis">on Github</a> and runs in under a second for most examples.</p>

<h2 id="why-synthesis-matters">Why synthesis matters</h2>
<h3 id="system-1-vs-system-2-thinking">System 1 vs System 2 thinking</h3>

<p>The book “Thinking Fast and Slow” by Daniel Kahneman differentiates between two kinds of thinking: system 1 vs system 2 thinking.</p>

<p>System 1 thinking is characterised by fast, intuitive judgments. It’s the thinking that you do when you’re not fully aware that you’re thinking. It is built up through repetition and experience. How we express language is an example of System 1 at work. In most situations, we don’t consciously think of what words to say. They stream out of us, dressing the thoughts we intend to communicate. System 1 thinking is the realm of the familiar. It’s not always right, but it gets you into a comfortable ballpark.</p>

<p>System 2 thinking, on the other hand, is slow and deliberate. It’s speaking in front of a crowd for the first time. It’s taking an exam for a class. It’s how we think when a context is outside (or at the bounds) of what we are used to. System 2 solves hard problems, but is time-consuming and taxing.</p>

<p>Human cognition is a constant dance between the two. Talking to a loved one is easy until the conversation topic is something sensitive. Writing a proof is hard until you discover the “trick” that could lead you to an answer. The two systems also interact in very strange ways. Ever had a eureka moment in the shower? Ever gotten a joke while driving, days after it was told? Problem-solving often means alternating between these two systems of thought.</p>

<h3 id="how-machines-think">How machines “think”</h3>
<p>An analogous dichotomy can be applied to artificial intelligence.</p>

<p>Machine learning (broadly defined as using statistics to learn patterns from data) is system 1 thinking. It is the statistical “intuition” gained from gorging on mounds of data. It’s easy to invalidate a lot of statistical approaches as memorisation (a lot of people will say LLMs are just a fancy autocomplete), but building the right statistical models is as difficult as building the right intuitions to solve problems. There has been a lot of ingenuity in this space over the last few decades.</p>

<p>But that’s not the entire story.</p>

<p>Until the advent of deep learning in the early 2010s, artificial intelligence was dominated by systems that tried to recreate system 2 thinking. These are “slow” algorithms that take time to discover the “right” answer. Program synthesis is one such approach. It tries to search through the space of instructions until it discovers one that solves a specified problem.</p>

<p>A growing number of researchers believe that getting to artificial general intelligence involves marrying the two approaches (what’s now referred to as neuro-symbolic AI). There have been some promising achievements in the field recently:</p>

<p>DeepMind’s AlphaGeometry combines a learned model with a symbolic deduction engine and massive synthetic training data to tackle Olympiad-style geometry: in a Nature-reported benchmark of 30 problems drawn from IMO 2000–2022, it solved 25 within the time limit, approaching a human gold-medalist’s performance. Later, an upgraded AlphaGeometry 2 paired with AlphaProof (a reinforcement-learning formal prover) solved 4 of 6 problems from IMO 2024—silver-medal level. If you want a quick, approachable walkthrough of how this works, <a href="https://www.youtube.com/watch?v=4NlrfOl0l8U">Aleph0 has a really good video on this</a>.</p>

<p>On the “reasoning LLM” front, OpenAI’s o3 pushed ARC-AGI scores sharply upward. François Chollet characterizes o3’s core as <a href="https://arcprize.org/blog/oai-o3-pub-breakthrough">a form of deep learning guided program search</a>: at test time the system explores and evaluates many candidate chains-of-thought—effectively short “programs” written in natural language—guided by a learned prior, and selects the best-scoring plan. Regardless of the exact internals, the result is stronger adaptation to novel tasks than earlier GPT-style models.</p>

<p>Stepping back, these examples point to a common pattern: use learning to propose promising moves, and use search/logic to compose, check, and refine them. That division of labor (neural intuition plus symbolic rigor) is increasingly the template for systems that need both creativity and reliability.</p>

<p>So, as we stand on the cusp of a future where synthesis looks as though it will make a resurgence in some incarnation, I thought it apt to write a series of practical blog posts on what it is.</p>

<h2 id="what-is-program-synthesis">What is program synthesis</h2>

<p>We’ll work from a definition offered in Armando Solar-Lezama’s <a href="https://people.csail.mit.edu/asolar/SynthesisCourse/index.htm">MIT class on the subject</a>.</p>

<p><em>Program Synthesis correspond to a class of techniques that are able to generate a program from a collection of artifacts that establish semantic and syntactic requirements for the generated code.</em></p>

<p>Program synthesis lives at the intersection of what you want (the specification) and how you look for it (the search). Syntheis searches through the space of candidate programs using a given criteria.</p>

<h3 id="searching-and-specification">Searching and Specification</h3>

<h4 id="specifying-the-problem">Specifying the problem</h4>

<p>A specification is any collection of clues that narrows down the set of candidate programs. Those clues can be semantic (what the output must mean) or syntactic (what the code must look like).</p>

<p>Synthesis problems are usually specified through:</p>
<ul>
  <li>Input–Output Examples (I/O): The friendliest form. “When input is Joshua Nkomo, output must be J. Nkomo.”</li>
  <li>Constraints / Properties (pre/postconditions):
Rules like “output must contain a full-stop,” “output length ≤ max(len(input), len(target)),” or “index must be within bounds.” These semantic checks prune huge regions of bad candidates even before running full tests.</li>
  <li>Types and Grammars (the DSL itself):
By choosing a small, typed vocabulary (our string blocks), you ban ill-formed programs outright. This is a syntactic specification baked into the language design.</li>
  <li>Sketches (templates with holes): “I’m pretty sure it’s Concat(Upper(Head(x)), ???)—fill in the ???.” You constrain structure and let the tool finish the details.</li>
  <li>Negative examples: “For input Steve, don’t output S. with a trailing space.” Prevents sneaky overfits.</li>
  <li>Resource hints / preferences (ranking): “Prefer shorter programs,” “penalize Substring with two dynamic indices,” etc. These are soft constraints used to rank ties.</li>
</ul>

<p>In our mini Flash-Fill program, the specification will be I/O examples.</p>

<h4 id="searching-the-space">Searching the space</h4>
<p>At its core, synthesis is a search problem. Several families of search exist:</p>

<ol>
  <li>Enumerative search</li>
  <li>Constraint-guided search</li>
  <li>Heuristic / ML-guided search</li>
</ol>

<p>We’ll focus on the latter two in subsequent posts. For this post we’ll use a simple, yet powerful, type of enumerative search.</p>

<h2 id="building-our-synthesiser">Building Our Synthesiser</h2>

<p><em>Follow along as you read the code on <a href="https://github.com/mchav/synthesis/blob/main/src/Enumerative.hs">Github</a></em></p>

<h3 id="starting-from-the-bottom">Starting from the bottom</h3>

<p>Here’s the key insight: if we have a language of simple operations (head, tail, concat, etc.), we can systematically combine them to build complex programs. It’s like having LEGO blocks - start with basic pieces and build increasingly complex structures until one solves the problem.</p>

<p>We’ll write a synthesiser that generates programs that can solve problems of the following nature:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Joshua Nkomo</td>
      <td>J. Nkomo</td>
    </tr>
    <tr>
      <td>Steve Biko</td>
      <td>S. Biko</td>
    </tr>
    <tr>
      <td>Jomo Kenyatta</td>
      <td>J. Kenyatta</td>
    </tr>
  </tbody>
</table>

<h3 id="the-building-blocks">The building blocks</h3>

<p>We usually start by defining what the “legal moves” of our problem are. The “program” part of program synthesis is typically a small, expressive set of instructions that can solve our specific problem. We call this set of instructions a domain specific language (DSL).</p>

<p>It is easy to view DSLs as a limitation. Why define a small language instead of generating any arbitrary code in a language like Python or Java? Doesn’t a DSL mean that our technique inherently lacks power and expressivity?</p>

<p>While a DSL can be viewed as a constraint, its contribution is far more profound than a simple restriction. The design of the DSL is deeply intertwined with the design of the search algorithm. A well-designed DSL exposes a set of operators and a program structure that are amenable to efficient search and, crucially, aggressive pruning of the search space. DSLs are how we embed domain knowledge into program synthesis.</p>

<p>The development of FlashFill provides a canonical example of this principle. The synthesiser’s DSL was not chosen arbitrarily; it was carefully engineered to enable an efficient top-down, divide-and-conquer search algorithm.</p>

<p>If a DSL is too small, we risk not being able to express the target transformation. If it’s too big, we drown in candidates and overfit to examples. We want a sweet spot: just expressive enough to cover the problem class, yet constrained enough that searches can complete in our lifetime.</p>

<p>After eyeballing our problem (string manipulation) we decide our language should support these operations:</p>

<ul>
  <li>Head — keep the first character (“Joshua” → “J”)</li>
  <li>Tail — drop the first character (“ Nkomo” → “Nkomo” after trimming a leading space)</li>
  <li>Lower / Upper — change case</li>
  <li>Concat — glue two strings together</li>
  <li>Substring start end x — slice a range from x</li>
  <li>Find needle haystack — index of needle inside haystack</li>
  <li>Constants like “.”, “ “</li>
  <li>A placeholder input value</li>
</ul>

<p>That’s enough to express lots of “data cleaning” tricks.</p>

<p>Given our mini-language, we can express our intended program as follows:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">firstInitial</span> <span class="o">=</span> <span class="kt">Upper</span><span class="p">(</span><span class="kt">Head</span><span class="p">(</span><span class="n">x</span><span class="p">))</span>

<span class="c1">-- Find the occurrence of the space, then drop it.</span>
<span class="n">lastName</span> <span class="o">=</span> <span class="kt">Tail</span><span class="p">(</span> <span class="kt">Substring</span><span class="p">(</span> <span class="kt">Find</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="n">x</span><span class="p">),</span> <span class="kt">End</span><span class="p">,</span> <span class="n">x</span> <span class="p">)</span> <span class="p">)</span>

<span class="kt">Concat</span> <span class="p">(</span>
 <span class="kt">Concat</span> <span class="p">(</span> <span class="n">firstInitial</span><span class="p">,</span> <span class="s">". "</span><span class="p">),</span>
 <span class="n">lastName</span>
<span class="p">)</span>

<span class="c1">-- Or written densely.</span>
<span class="kt">Concat</span><span class="p">(</span>
 <span class="kt">Concat</span><span class="p">(</span> <span class="kt">Upper</span><span class="p">(</span><span class="kt">Head</span><span class="p">(</span><span class="n">x</span><span class="p">)),</span> <span class="s">". "</span> <span class="p">),</span>
 <span class="kt">Tail</span><span class="p">(</span> <span class="kt">Substring</span><span class="p">(</span> <span class="kt">Find</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="n">x</span><span class="p">),</span> <span class="kt">End</span><span class="p">,</span> <span class="n">x</span> <span class="p">)</span> <span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Our challenge is to see if we can come up with a similar program using synthesis.</p>

<p>First, we have to define our language programmatically:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Program</span> <span class="n">a</span> <span class="kr">where</span>
 <span class="kt">Concat</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">Substring</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">Int</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">Int</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">Tail</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">Head</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">SValue</span> <span class="o">::</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">Variable</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">String</span>
 <span class="kt">Find</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">Int</span>
 <span class="kt">Start</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">Int</span>
 <span class="kt">End</span> <span class="o">::</span> <span class="kt">Program</span> <span class="kt">Int</span>
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Program a</code> means “a little recipe (an expression tree) that, when we run it, produces a value of type a.” In our code, there are only two kinds of results: <code class="language-plaintext highlighter-rouge">Program String</code> and <code class="language-plaintext highlighter-rouge">Program Int</code>.</p>

<p>The <code class="language-plaintext highlighter-rouge">:: Program String</code> or <code class="language-plaintext highlighter-rouge">:: Program Int</code> parts tell us what kind of result each operation produces:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Program String</code> = produces text</li>
  <li><code class="language-plaintext highlighter-rouge">Program Int</code> = produces a number (usually a position)</li>
</ul>

<p>The arrows (-&gt;) show what inputs each operation needs. For example:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Tail :: Program String -&gt; Program String</code> means “Tail needs a string-producing program as input and produces a string-producing program as output”</li>
</ul>

<p>This structure is compositional. We can define longer programs in terms of these basic elements as we did in the solution above. The synthesis community typically uses functional programming languages because they express these relationships more clearly.</p>

<p>The interpreter for our DSL can also be defined recursively:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | This is the leaf of our tree - string wrapped as a program</span>
<span class="c1">-- or rather a program that returns a string constant.</span>
<span class="n">interpret</span> <span class="p">(</span><span class="kt">SValue</span> <span class="n">v</span><span class="p">)</span> <span class="o">=</span> <span class="n">v</span>
<span class="cd">-- | Our string operations</span>
<span class="n">interpret</span> <span class="p">(</span><span class="kt">Tail</span> <span class="n">v</span><span class="p">)</span> <span class="o">=</span> <span class="n">drop</span> <span class="mi">1</span> <span class="p">(</span><span class="n">interpret</span> <span class="n">v</span><span class="p">)</span>
<span class="n">interpret</span> <span class="p">(</span><span class="kt">Head</span> <span class="n">v</span><span class="p">)</span> <span class="o">=</span> <span class="n">take</span> <span class="mi">1</span> <span class="p">(</span><span class="n">interpret</span> <span class="n">v</span><span class="p">)</span>
<span class="n">interpret</span> <span class="p">(</span><span class="kt">Concat</span> <span class="n">l</span> <span class="n">r</span><span class="p">)</span> <span class="o">=</span> <span class="n">interpret</span> <span class="n">l</span> <span class="o">++</span> <span class="n">interpret</span> <span class="n">r</span>
<span class="n">interpret</span> <span class="p">(</span><span class="kt">Substring</span> <span class="n">start</span> <span class="n">end</span> <span class="n">g</span><span class="p">)</span> <span class="o">=</span> <span class="o">...</span>
</code></pre></div></div>

<p>Think of each <code class="language-plaintext highlighter-rouge">Program</code> as a tree node: <code class="language-plaintext highlighter-rouge">SValue "foo"</code> is a leaf; <code class="language-plaintext highlighter-rouge">Tail v</code>, <code class="language-plaintext highlighter-rouge">Head v</code> have one child; <code class="language-plaintext highlighter-rouge">Concat l r</code> has two children; <code class="language-plaintext highlighter-rouge">Substring start end g</code> has three. <code class="language-plaintext highlighter-rouge">interpret</code> walks that tree. Each line says, “if the node looks like this shape, do this.” Haskell lets us write one equation per shape of input; the one that matches is used.</p>

<p>Now we can define our also search strategy programatically. To recap:</p>

<ol>
  <li><strong>Start small</strong>. Begin with the identity function and the input x. Optionally, seed a few helpful constants (like “ “ or “.”) and any characters shared across all inputs/outputs.</li>
  <li><strong>Grow candidates</strong>. Repeatedly build bigger programs by:
    <ul>
      <li>Wrapping existing ones with Head, Tail, etc.</li>
      <li>Concatenating any two existing ones.</li>
      <li>Making substrings using Find, Start, and End.</li>
    </ul>
  </li>
  <li><strong>Interpret and test</strong>. Run each candidate on the provided examples; keep only those that don’t explode in size and (eventually) those that match all examples.</li>
  <li><strong>Iterate by depth</strong>. Increase the “size limit” and repeat until we succeed or give up.</li>
</ol>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">search</span> <span class="o">::</span>
 <span class="c1">-- | Examples</span>
 <span class="p">[(</span><span class="kt">String</span><span class="p">,</span> <span class="kt">String</span><span class="p">)]</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Variables of interest.</span>
 <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Existing Programs</span>
 <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Search depth</span>
    <span class="kt">Int</span> <span class="o">-&gt;</span>
 <span class="c1">-- | Maybe is an optional type meaning we might end with no program found.</span>
    <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">)</span>
<span class="n">search</span> <span class="n">examples</span> <span class="n">variables</span> <span class="n">programs</span> <span class="n">d</span> <span class="o">=</span>
    <span class="kr">if</span> <span class="n">d</span> <span class="o">==</span> <span class="mi">0</span> <span class="kr">then</span> <span class="kt">Nothing</span>  <span class="o">--|</span> <span class="kt">Give</span> <span class="n">up</span> <span class="kr">if</span> <span class="n">we've</span> <span class="n">gone</span> <span class="n">deep</span> <span class="n">enough</span>
    <span class="kr">else</span>
        <span class="kr">case</span> <span class="n">findFirst</span> <span class="n">ps</span> <span class="kr">of</span>
 <span class="kt">Just</span> <span class="n">p</span> <span class="o">-&gt;</span> <span class="kt">Just</span> <span class="n">p</span>
 <span class="kt">Nothing</span> <span class="o">-&gt;</span> <span class="n">search</span> <span class="n">examples</span> <span class="n">variables</span> <span class="p">(</span><span class="n">generatePrograms</span> <span class="n">variables</span> <span class="n">programs</span><span class="p">)</span> <span class="p">(</span><span class="n">d</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
  <span class="kr">where</span>
 <span class="n">findFirst</span> <span class="kt">[]</span> <span class="o">=</span> <span class="kt">Nothing</span>
 <span class="n">findFirst</span> <span class="p">(</span><span class="n">p</span> <span class="o">:</span> <span class="n">ps'</span><span class="p">)</span>
 <span class="o">|</span> <span class="n">satisfiesExamples</span> <span class="n">examples</span> <span class="n">p</span> <span class="o">=</span> <span class="kt">Just</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="n">findFirst</span> <span class="n">ps'</span>
</code></pre></div></div>

<p><em>The <code class="language-plaintext highlighter-rouge">where</code> section defines some variable bindings, and he core of the logic is in the <code class="language-plaintext highlighter-rouge">if</code> statement.</em></p>

<p>The entire system is recursive:</p>
<ul>
  <li>Generation 0 produces Generation 1</li>
  <li>Generation 1 produces Generation 2</li>
  <li>Each generation builds on all previous work</li>
</ul>

<p>Notice, specifically, that the input to the new search is all the generated programs from the current generation, and the other inputs (except for the depth parameter) are left unchanged.</p>

<p><code class="language-plaintext highlighter-rouge">search examples variables (generatePrograms variables programs) (d - 1)</code></p>

<h3 id="generate-programs-systematically">Generate Programs Systematically</h3>

<p>The <code class="language-plaintext highlighter-rouge">generatePrograms</code> function in our code employs four distinct strategies simultaneously, each exploring a different dimension of the program space:</p>

<h4 id="strategy-1-simple-transformations">Strategy 1: Simple Transformations</h4>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[ comp transform p | p &lt;- existingPrograms, transform &lt;- [Tail, Head, Lower, Upper] ]
</code></pre></div></div>

<p>This strategy takes every existing program and applies simple one-step transformations. If we have a program <code class="language-plaintext highlighter-rouge">p</code> that produces “HELLO”, this generates:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Tail(p)</code> → “ELLO” (remove first character)</li>
  <li><code class="language-plaintext highlighter-rouge">Head(p)</code> → “H” (keep only first character)</li>
</ul>

<h4 id="strategy-2-concatenation-combinations">Strategy 2: Concatenation Combinations</h4>
<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span> <span class="nf">\</span><span class="n">v</span> <span class="o">-&gt;</span> <span class="kt">Concat</span> <span class="p">(</span><span class="n">p</span> <span class="n">v</span><span class="p">)</span> <span class="p">(</span><span class="n">q</span> <span class="n">v</span><span class="p">)</span> <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span><span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span> <span class="p">]</span>
</code></pre></div></div>

<p>This creates programs that combine the outputs of two existing programs. If we have:</p>
<ul>
  <li>Program <code class="language-plaintext highlighter-rouge">p</code>: extracts first word</li>
  <li>Program <code class="language-plaintext highlighter-rouge">q</code>: extracts last word</li>
</ul>

<p>This generates <code class="language-plaintext highlighter-rouge">Concat(p, q)</code>: first word + last word</p>

<p><strong>Example Evolution</strong>:</p>
<ul>
  <li>Generation 1: <code class="language-plaintext highlighter-rouge">p = identity</code> (returns input as-is)</li>
  <li>Generation 2: <code class="language-plaintext highlighter-rouge">Concat(p, p)</code> (doubles the input: “hi” → “hihi”)</li>
  <li>Generation 3: <code class="language-plaintext highlighter-rouge">Concat(Concat(p, p), p)</code> (triples: “hi” → “hihihi”)</li>
</ul>

<p><strong>Combinatorial Explosion</strong>: With just 10 existing programs, this strategy alone generates 10 × 10 = 100 new programs! This is something we must contain to make this approach useful.</p>

<h4 id="strategy-3--4-variable-based-transformations">Strategy 3 &amp; 4: Variable-Based Transformations</h4>

<p>These are the most sophisticated strategies. They use discovered “variables” (important strings found in the examples) to create complex transformations.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[ comp t p | v &lt;- vars, p &lt;- existingPrograms, t &lt;- transformsWithVar v ]
[ comp p t | v &lt;- vars, p &lt;- existingPrograms, t &lt;- transformsWithVar v ]
</code></pre></div></div>

<p>Let’s say the system discovered “@” as an important variable. It generates programs like:</p>

<p><strong>Pre-processing with variable</strong> (Strategy 3):</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">Concat("@", existing_program)</code> - Prepend @ to result</li>
  <li><code class="language-plaintext highlighter-rouge">Substring(0, Find("@", input), existing_program)</code> - Everything before @</li>
  <li><code class="language-plaintext highlighter-rouge">Substring(Find("@", input), End, existing_program)</code> - Everything after @</li>
</ol>

<p><strong>Post-processing with variable</strong> (Strategy 4):</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">existing_program</code> then <code class="language-plaintext highlighter-rouge">Concat</code> with “@”</li>
  <li><code class="language-plaintext highlighter-rouge">existing_program</code> then extract until “@”</li>
  <li><code class="language-plaintext highlighter-rouge">existing_program</code> then extract after “@”</li>
</ol>

<h4 id="a-concrete-generation-example">A Concrete Generation Example</h4>

<p>Let’s trace through generating a program for email domain extraction:</p>

<h5 id="initial-state-generation-0">Initial State (Generation 0)</h5>
<ul>
  <li>Programs: <code class="language-plaintext highlighter-rouge">[identity]</code> (just returns input)</li>
  <li>Variables: <code class="language-plaintext highlighter-rouge">["@", "."]</code> (discovered from examples)</li>
</ul>

<h5 id="generation-1">Generation 1</h5>
<p>The system generates programs like:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Tail(identity)</code> → removes first char</li>
  <li><code class="language-plaintext highlighter-rouge">Head(identity)</code> → keeps first char</li>
  <li><code class="language-plaintext highlighter-rouge">Concat(identity, identity)</code> → doubles the input</li>
  <li><code class="language-plaintext highlighter-rouge">Concat("@", identity)</code> → prepends @</li>
  <li><code class="language-plaintext highlighter-rouge">Concat(identity, "@")</code> → appends @</li>
  <li><code class="language-plaintext highlighter-rouge">Substring(Find("@", identity), End, identity)</code> → <strong>everything after @</strong></li>
</ul>

<p>If the solution required more complexity, Generation 2 would create even more programs:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Tail(Substring(Find("@", identity), End, identity))</code></li>
  <li><code class="language-plaintext highlighter-rouge">Concat("domain: ", Substring(Find("@", identity), End, identity))</code></li>
  <li><code class="language-plaintext highlighter-rouge">Tail(Substring(Find("@", identity), End, identity))</code></li>
</ul>

<p>The final function looks like this:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | Generate the next “ring” of candidate programs from:</span>
<span class="c1">--   * vars: string-valued building blocks discovered from examples (e.g., " ", ".", "@", etc.)</span>
<span class="c1">--   * existingPrograms: functions that turn an input Program String into a Program String</span>
<span class="c1">--   The result is a list of new transformers, ordered (smallest AST first) by a simple size metric.</span>
<span class="n">generatePrograms</span>
 <span class="o">::</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span>                      <span class="c1">-- ^ vars (constants/placeholders from examples)</span>
 <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span>    <span class="c1">-- ^ existing candidate transformers</span>
 <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span>    <span class="c1">-- ^ newly generated transformers (sorted by size)</span>
<span class="n">generatePrograms</span> <span class="n">vars</span> <span class="n">existingPrograms</span> <span class="o">=</span>
  <span class="c1">-- Sort candidates so we explore simpler/smaller programs first.</span>
  <span class="c1">-- We estimate size by applying each transformer to a dummy leaf (SValue "")</span>
  <span class="c1">-- and measuring the generated AST with gSize.</span>
  <span class="kt">L</span><span class="o">.</span><span class="n">sortBy</span>
 <span class="p">(</span><span class="nf">\</span><span class="n">p</span> <span class="n">q</span> <span class="o">-&gt;</span> <span class="n">gSize</span> <span class="p">(</span><span class="n">p</span> <span class="p">(</span><span class="kt">SValue</span> <span class="s">""</span><span class="p">))</span> <span class="p">`</span><span class="n">compare</span><span class="p">`</span> <span class="n">gSize</span> <span class="p">(</span><span class="n">q</span> <span class="p">(</span><span class="kt">SValue</span> <span class="s">""</span><span class="p">)))</span>

    <span class="c1">-- 1) Unary wrappers: for every existing transformer p,</span>
    <span class="c1">--    wrap it once with a simple string op (Tail/Head/Lower/Upper).</span>
    <span class="c1">--    `comp transform p` means “apply p, then apply transform”.</span>
 <span class="p">(</span> <span class="p">[</span> <span class="n">comp</span> <span class="n">transform</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">transform</span> <span class="o">&lt;-</span> <span class="p">[</span><span class="kt">Tail</span><span class="p">,</span> <span class="kt">Head</span><span class="p">,</span> <span class="kt">Lower</span><span class="p">,</span> <span class="kt">Upper</span><span class="p">]</span>
 <span class="p">]</span>

    <span class="c1">-- 2) All ordered concatenations of existing transformers:</span>
    <span class="c1">--    build a function that, given an input v, concatenates (p v) and (q v).</span>
    <span class="c1">--    This grows breadth by combining previously discovered behaviours.</span>
 <span class="o">++</span> <span class="p">[</span> <span class="nf">\</span><span class="n">v</span> <span class="o">-&gt;</span> <span class="kt">Concat</span> <span class="p">(</span><span class="n">p</span> <span class="n">v</span><span class="p">)</span> <span class="p">(</span><span class="n">q</span> <span class="n">v</span><span class="p">)</span>
 <span class="o">|</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">q</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">]</span>

    <span class="c1">-- 3) Variable-aware transforms (pre-compose):</span>
    <span class="c1">--    for each discovered var v (a Program String), and each existing p,</span>
    <span class="c1">--    create transforms t that *mention* v (prepend/append/slice around v),</span>
    <span class="c1">--    then do `t . p` via `comp t p`.</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">comp</span> <span class="n">t</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">v</span> <span class="o">&lt;-</span> <span class="n">vars</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">t</span> <span class="o">&lt;-</span> <span class="n">transformsWithVar</span> <span class="n">v</span>
 <span class="p">]</span>

    <span class="c1">-- 4) Variable-aware transforms (post-compose):</span>
    <span class="c1">--    symmetric to (3), but apply p first and then t (`p . t` via `comp p t`),</span>
    <span class="c1">--    so we explore both “before p” and “after p” placements of the same motif.</span>
 <span class="o">++</span> <span class="p">[</span> <span class="n">comp</span> <span class="n">p</span> <span class="n">t</span>
 <span class="o">|</span> <span class="n">v</span> <span class="o">&lt;-</span> <span class="n">vars</span>
 <span class="p">,</span> <span class="n">p</span> <span class="o">&lt;-</span> <span class="n">existingPrograms</span>
 <span class="p">,</span> <span class="n">t</span> <span class="o">&lt;-</span> <span class="n">transformsWithVar</span> <span class="n">v</span>
 <span class="p">]</span>
 <span class="p">)</span>
  <span class="kr">where</span>
 <span class="c1">-- | Small library of transforms parameterised by a discovered variable `v`.</span>
 <span class="c1">--   Each lambda expects a string-producing Program `v'` (usually the current input),</span>
 <span class="c1">--   and returns a new string-producing Program that uses `v` in a useful way.</span>
 <span class="n">transformsWithVar</span> <span class="n">v</span> <span class="o">=</span>
 <span class="p">[</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Concat</span> <span class="n">v</span> <span class="n">v'</span>                           <span class="c1">-- prepend v</span>
 <span class="p">,</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Concat</span> <span class="n">v'</span> <span class="n">v</span>                           <span class="c1">-- append v</span>
 <span class="p">,</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Substring</span> <span class="kt">Start</span> <span class="p">(</span><span class="kt">Find</span> <span class="n">v</span> <span class="n">v'</span><span class="p">)</span> <span class="n">v'</span>        <span class="c1">-- prefix of v' up to first v</span>
 <span class="p">,</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Substring</span> <span class="kt">Start</span> <span class="p">(</span><span class="kt">Find</span> <span class="n">v'</span> <span class="n">v</span><span class="p">)</span> <span class="n">v</span>         <span class="c1">-- prefix of v up to first v'</span>
 <span class="p">,</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Substring</span> <span class="p">(</span><span class="kt">Find</span> <span class="n">v</span> <span class="n">v'</span><span class="p">)</span> <span class="kt">End</span> <span class="n">v'</span>          <span class="c1">-- suffix of v' after first v</span>
 <span class="p">,</span> <span class="nf">\</span><span class="n">v'</span> <span class="o">-&gt;</span> <span class="kt">Substring</span> <span class="p">(</span><span class="kt">Find</span> <span class="n">v'</span> <span class="n">v</span><span class="p">)</span> <span class="kt">End</span> <span class="n">v</span>           <span class="c1">-- suffix of v after first v'</span>
 <span class="p">]</span>

<span class="cd">-- | Helper intuition (defined elsewhere in our code):</span>
<span class="c1">-- comp :: (Program String -&gt; Program String)</span>
<span class="c1">--      -&gt; (Program String -&gt; Program String)</span>
<span class="c1">--      -&gt; (Program String -&gt; Program String)</span>
<span class="c1">-- comp = (.)</span>
<span class="c1">--</span>
<span class="c1">-- gSize :: Program String -&gt; Int</span>
<span class="c1">-- gSize = ... -- counts AST nodes to bias search toward simpler programs</span>
</code></pre></div></div>

<p>Our approach is like evolution - each generation inherits and combines traits from its ancestors, occasionally producing exactly what we need. Given enough time and memory, it will find all relevant programs expressible in the language.</p>

<p>The key caveat here, though, is “enough time and memory.” We’d like to find these programs in our lifetime. How do we tame the growth of our generation function?</p>

<p>Our program, as it exists, runs fine for small transformations, e.g. <code class="language-plaintext highlighter-rouge">[("Joshua", "J"), ("Steve", "S")]</code>, but for our target problem, my computer runs out of memory and kills the program (even after running for close to an hour).</p>

<p>We need to be smarter about how we search the space/</p>

<h2 id="the-exponential-growth-problem">The Exponential Growth Problem</h2>

<p>Exponential growth is the supervillain of the synthesis story. Looking at the code above, we can see why! We generate A LOT of programs at each step.</p>

<p>Let’s calculate the growth rate:</p>

<p><strong>Generation 0</strong>: 1 program (identity)
<strong>Generation 1</strong>: ~30 programs (depending on variables discovered)
<strong>Generation 2</strong>: ~900 programs (30 existing × 30 combinations)
<strong>Generation 3</strong>: ~27,000 programs
<strong>Generation 4</strong>: ~810,000 programs</p>

<p>The sophistication of an enumerative synthesiser is largely measured by its ability to intelligently prune the search space. Pruning strategies exist on a spectrum, from reactive deduplication of redundant programs to proactive elimination of entire branches of the search tree. L</p>

<p>Let’s rewrite our search to include some extra pruning and deduplication steps as examples.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">search</span> <span class="n">examples</span> <span class="n">variables</span> <span class="n">programs</span> <span class="n">d</span>
 <span class="o">|</span> <span class="n">d</span> <span class="o">==</span> <span class="mi">0</span> <span class="o">=</span> <span class="kt">Nothing</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span>
        <span class="kr">case</span> <span class="n">findFirst</span> <span class="n">ps</span> <span class="kr">of</span>
 <span class="kt">Just</span> <span class="n">p</span> <span class="o">-&gt;</span> <span class="kt">Just</span> <span class="n">p</span>
 <span class="kt">Nothing</span> <span class="o">-&gt;</span> <span class="n">search</span> <span class="n">examples</span> <span class="n">variables</span> <span class="p">(</span><span class="n">generatePrograms</span> <span class="n">variables</span> <span class="n">ps</span><span class="p">)</span> <span class="p">(</span><span class="n">d</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
  <span class="kr">where</span>
 <span class="n">inputs</span> <span class="o">=</span> <span class="n">map</span> <span class="n">fst</span> <span class="n">examples</span>  <span class="c1">-- | separate the inputs from the outputs.</span>
 <span class="n">ps</span> <span class="o">=</span> <span class="n">prune</span> <span class="n">examples</span> <span class="o">$</span> <span class="n">deduplicate</span> <span class="n">inputs</span> <span class="n">programs</span>  <span class="c1">-- | Now define a new set of deduplicated, pruned programs.</span>
 <span class="n">findFirst</span> <span class="kt">[]</span> <span class="o">=</span> <span class="kt">Nothing</span>
 <span class="n">findFirst</span> <span class="p">(</span><span class="n">p</span> <span class="o">:</span> <span class="n">ps'</span><span class="p">)</span>
 <span class="o">|</span> <span class="n">satisfiesExamples</span> <span class="n">examples</span> <span class="n">p</span> <span class="o">=</span> <span class="kt">Just</span> <span class="n">p</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="n">findFirst</span> <span class="n">ps'</span>
</code></pre></div></div>

<p>We haven’t defined these functions yet. We’ll go over each of them, but we must keep in mind how they fit into our overall program.</p>

<h3 id="deduplication">Deduplication</h3>
<p>Continuing a search for duplicate programs is a waste of time. Pruning a single redundant sub-program results in exponential savings, as the synthesiser is spared from exploring the exponentially many larger programs that could have been constructed using that redundant component.</p>

<p>But what does it mean for two programs to be equal?</p>

<p>We’ll apply a sort of duck test to the programs. If, given the same inputs, they produce the same outputs, then we consider them to be the same program. For example, <code class="language-plaintext highlighter-rouge">2 * x</code> and <code class="language-plaintext highlighter-rouge">x + x</code> would pass this equality duck test. They produce the same output for every real number. This is called a type of equality, observational equivalence. It is a practical approximation of true semantic equivalence. Instead of performing a costly formal proof to determine if two programs are identical for all possible inputs, the synthesiser uses the given examples as a proxy.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">deduplicate</span> <span class="o">::</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span>
<span class="n">deduplicate</span> <span class="n">inputs</span> <span class="o">=</span> <span class="n">go</span> <span class="kt">[]</span>
  <span class="kr">where</span>
 <span class="n">go</span> <span class="kr">_</span> <span class="kt">[]</span> <span class="o">=</span> <span class="kt">[]</span>
 <span class="n">go</span> <span class="n">seen</span> <span class="p">(</span><span class="n">x</span> <span class="o">:</span> <span class="n">xs</span><span class="p">)</span>
 <span class="o">|</span> <span class="n">any</span> <span class="p">(</span><span class="n">equivalent</span> <span class="n">inputs</span> <span class="n">x</span><span class="p">)</span> <span class="n">seen</span> <span class="o">=</span> <span class="n">go</span> <span class="n">seen</span> <span class="n">xs</span>
 <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span> <span class="n">x</span> <span class="o">:</span> <span class="n">go</span> <span class="p">(</span><span class="n">x</span> <span class="o">:</span> <span class="n">seen</span><span class="p">)</span> <span class="n">xs</span>

<span class="cd">-- | Checks if two programs generate the same outputs given all the same inputs.</span>
<span class="n">equivalent</span> <span class="o">::</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span>
<span class="n">equivalent</span> <span class="n">inputs</span> <span class="n">p1</span> <span class="n">p2</span> <span class="o">=</span> <span class="n">all</span> <span class="p">((</span><span class="nf">\</span><span class="n">i</span> <span class="o">-&gt;</span> <span class="n">interpret</span> <span class="p">(</span><span class="n">p1</span> <span class="n">i</span><span class="p">)</span> <span class="o">==</span> <span class="n">interpret</span> <span class="p">(</span><span class="n">p2</span> <span class="n">i</span><span class="p">))</span> <span class="o">.</span> <span class="kt">SValue</span><span class="p">)</span> <span class="n">inputs</span>
</code></pre></div></div>

<p><em>Our deduplication function is written for simplicity, not performance. The repo contains a more efficient deduplication function.</em></p>

<p>This function pays dividends as the search grows because more programs are more likely to be similar as we search deeper. Especially if we have invertible functions in our DSL. Including both <code class="language-plaintext highlighter-rouge">+</code> and <code class="language-plaintext highlighter-rouge">-</code> in a program over integers could lead to programs like <code class="language-plaintext highlighter-rouge">x + 1 - 1 + 1 - 1</code> (instead of just <code class="language-plaintext highlighter-rouge">x</code>).</p>

<h3 id="pruning">Pruning</h3>

<p>A lot of ingenuity and domain knowledge go into pruning enumerative searches. Chess engines like Stockfish (a search-based chess engine) use pruning to zero in on the board positions that are more likely to win. In our case, we can employ a simple pruning mechanism:  if the output of the program is bigger than both the input and output, we might be straying away from a solution.</p>

<p>We must be careful how we prune since we could discard a part of the tree that would have been useful to explore. If a prune rule could remove a real solution, make it a ranking/bias (lower priority), not a hard filter. A lot of papers that suggest pruning search spaces in some way will provide elaborate proofs of correctness about their pruning strategy.</p>

<p>We can safely prune programs whose output is bigger than the input or output. For string related problems long output usually means we are straying away from the solution.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | For this problem we prune based on length.</span>
<span class="n">prune</span> <span class="o">::</span> <span class="p">[(</span><span class="kt">String</span><span class="p">,</span> <span class="kt">String</span><span class="p">)]</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Program</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Program</span> <span class="kt">String</span><span class="p">]</span>
<span class="n">prune</span> <span class="n">examples</span> <span class="n">ps</span> <span class="o">=</span> <span class="n">pruned</span>
    <span class="kr">where</span>
        <span class="n">pruned</span> <span class="o">=</span> <span class="n">filter</span> <span class="p">(</span><span class="nf">\</span><span class="n">p</span> <span class="o">-&gt;</span> <span class="n">all</span> <span class="p">(</span><span class="nf">\</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">o</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">length</span> <span class="p">(</span><span class="n">interpret</span> <span class="p">(</span><span class="n">p</span> <span class="p">(</span><span class="kt">SValue</span> <span class="n">i</span><span class="p">)))</span> <span class="o">&lt;=</span> <span class="n">max</span> <span class="p">(</span><span class="n">length</span> <span class="n">i</span><span class="p">)</span> <span class="p">(</span><span class="n">length</span> <span class="n">o</span><span class="p">))</span> <span class="n">examples</span><span class="p">)</span> <span class="n">ps</span>
</code></pre></div></div>

<h3 id="speed-up">Speed up</h3>
<p>After both deduplication and pruning, the program takes under a second to synthesise a function that solves our problem.</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
    <span class="kr">let</span> <span class="n">examples</span> <span class="o">=</span> <span class="p">[</span> <span class="p">(</span><span class="s">"Jomo Kenyatta"</span><span class="p">,</span> <span class="s">"J. Kenyatta"</span><span class="p">),</span> <span class="p">(</span><span class="s">"Steve Biko"</span><span class="p">,</span> <span class="s">"S. Biko"</span><span class="p">),</span> <span class="p">(</span><span class="s">"Joshua Nkomo"</span><span class="p">,</span> <span class="s">"J. Nkomo"</span><span class="p">)]</span>
    <span class="kr">let</span> <span class="n">variables</span> <span class="o">=</span> <span class="p">[</span><span class="kt">SValue</span> <span class="s">"."</span><span class="p">]</span>
 <span class="n">print</span> <span class="p">(</span><span class="n">search</span> <span class="n">examples</span> <span class="p">[</span><span class="kt">SValue</span> <span class="s">"."</span><span class="p">]</span> <span class="mi">5</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$:~/code/synthesis$ time cabal run -O2
Right (\Variable -&gt; Concat (Head (Head Variable)) (Concat (SValue ".") (Substring (Find (SValue " ") Variable) End Variable)))

real    0m0.539s
user    0m0.460s
sys 0m0.073s
</code></pre></div></div>

<p>Now running this function on some input it hasn’t seen:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cabal repl
ghci&gt; myFunction v = Concat (Head (Head (SValue v))) (Concat (SValue ".") (Substring (Find (SValue " ") (SValue v)) End (SValue v)))
ghci&gt; interpret (f "Patrice Lumumba")
"P. Lumumba"
ghci&gt; interpret (f "Jason Moyo")
"J. Moyo"
</code></pre></div></div>

<p>This generation strategy represents a balance between mathematical completeness and practical efficiency - systematically exploring an infinite space while remaining computationally tractable for real-world problems.</p>

<h3 id="lets-trace-through-the-example">Let’s Trace Through the Example</h3>

<p>Let’s see how the synthesiser solves our name formatting problem:</p>

<p><strong>Input examples:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>("Joshua Nkomo", "J. Nkomo")
("Steve Biko", "S. Biko")
</code></pre></div></div>

<p><strong>Generation 1:</strong> Start with <code class="language-plaintext highlighter-rouge">[Variable]</code></p>
<ul>
  <li>Test: <code class="language-plaintext highlighter-rouge">Variable</code> produces “Joshua Nkomo” ✗</li>
</ul>

<p><strong>Generation 2:</strong> Generate from <code class="language-plaintext highlighter-rouge">[Variable]</code></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Programs generated: [Head Variable, Tail Variable, Upper Variable, ...]
Testing: Head Variable -&gt; "J" ✗
Testing: Tail Variable -&gt; "oshua Nkomo" ✗
...
None match
</code></pre></div></div>

<p><strong>Generation 3:</strong> Generate from Generation 2 programs</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Programs generated include:
- Concat (Head Variable) (SValue ".")
 -&gt; "J." (closer but not complete) ✗
- Upper (Head Variable)
 -&gt; "J" ✗
- Substring (Find (SValue " ") Variable) End Variable
 -&gt; " Nkomo" (found the last name with space) ✗
- ...
</code></pre></div></div>

<p><strong>Generation 4:</strong> Now we’re combining Generation 3 programs</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Testing: Concat (Concat (Upper (Head Variable)) (SValue ".")) 
 (Tail (Substring (Find (SValue " ") Variable) End Variable))

Step by step on "Joshua Nkomo":
1. Head Variable -&gt; "J"
2. Upper "J" -&gt; "J"
3. Concat "J" "." -&gt; "J."
4. Find " " in "Joshua Nkomo" -&gt; position 6
5. Substring from 6 to End -&gt; " Nkomo"
6. Tail " Nkomo" -&gt; "Nkomo"
7. Concat "J." "Nkomo" -&gt; "J. Nkomo" ✓

Checking "Steve Biko": -&gt; "S. Biko" ✓

SOLUTION FOUND!
</code></pre></div></div>

<h2 id="the-synthesis-landscape-deductive-enumerative-and-hybrid-systems">The Synthesis Landscape: Deductive, Enumerative, and Hybrid Systems</h2>

<p>The field of synthesis encompasses a diverse range of algorithmic philosophies, and the state-of-the-art often lies in combining their respective strengths.</p>

<ul>
  <li>
    <p><strong>Deductive Synthesis:</strong> In contrast to the “generate-and-test” approach of enumeration, deductive synthesis is a more constructive approach. It treats synthesis as a theorem-proving task, where a program is derived from a logical specification through a series of deductive steps. This process is often highly efficient and produces programs that are correct by construction. However, pure deductive synthesis is typically applicable only to specific domains and requires specifications in formal logic, making it less general than enumerative search.</p>
  </li>
  <li>
    <p><strong>Hybrid Approaches:</strong> Recognising the trade-offs between generality and efficiency, much of modern synthesis research focuses on hybrid systems that combine the best of both worlds.</p>
    <ul>
      <li><strong>Divide-and-Conquer:</strong> This strategy breaks a large, complex synthesis problem into smaller, more manageable sub-problems. For example, a synthesiser might separately enumerate small expressions that are correct on different subsets of the input examples, and then synthesise predicates to distinguish between these subsets, finally combining them into a single solution using a decision tree structure.</li>
      <li><strong>Enumerative + Deductive/Deductive:</strong> Another powerful hybrid model uses bottom-up enumeration to generate a library of small, useful program components. A more powerful, top-down deductive or propagation-based algorithm then uses this library of components to construct the final, larger program. This approach, exemplified by systems like<br />
 Duet and DryadSynth leverage the scalability of deductive methods for composing large programs while retaining the generality of enumeration for discovering the fundamental building blocks.</li>
    </ul>
  </li>
</ul>

<table>
  <thead>
    <tr>
      <th>Paradigm</th>
      <th>Core Mechanism</th>
      <th>Primary Strengths</th>
      <th>Inherent Weaknesses</th>
      <th>Example System(s)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Enumerative (Bottom-Up)</strong></td>
      <td>Systematically build larger programs from smaller ones; prune via observational equivalence.</td>
      <td>General, finds the smallest solutions.</td>
      <td>Suffers from exponential state explosion (the “Scalability Wall”).</td>
      <td>eusolver</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <td><strong>Enumerative (Top-Down)</strong></td>
      <td>Start with a “hole” and recursively fill it in; prune via abstract interpretation.</td>
      <td>Can prune large, invalid search branches early.</td>
      <td>Pruning effectiveness depends heavily on the quality of the abstraction.</td>
      <td>Moito</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <td><strong>Deductive</strong></td>
      <td>Construct a program as a proof from a logical specification.</td>
      <td>Highly efficient, formally correct by construction.</td>
      <td>Limited to specific domains and grammar types.</td>
      <td>SynQuid</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <td><strong>Hybrid (Divide &amp; Conquer)</strong></td>
      <td>Combine enumerative/deductive methods to solve sub-problems and compose solutions.</td>
      <td>Overcomes scalability limits of pure enumeration; more general than pure deduction.</td>
      <td>Complexity in decomposition and composition logic.</td>
      <td>Duet, DryadSynth</td>
    </tr>
  </tbody>
  <tbody>
    <tr>
      <td><strong>Neuro-Symbolic (LLM-Guided)</strong></td>
      <td>Use a neural model to guide a symbolic search algorithm.</td>
      <td>Leverages pattern recognition of LLMs to guide search intelligently.</td>
      <td>Can be a black box; guidance may be unreliable; high computational cost.</td>
      <td>AlphaGeometry, OpenAI o3</td>
    </tr>
  </tbody>
</table>

<h2 id="whats-next">What’s Next?</h2>

<p>In the next post, we’ll explore how to specify programs using more than just input-output examples, including partial specifications and logical constraints. We’ll also look at how modern synthesis systems use machine learning to guide the search process, making them fast enough for interactive use.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Introduction This post kicks off a hands-on series about program synthesis—the art of teaching machines how to generate code. We’ll build a tiny, FlashFill-style synthesiser that learns to turn strings like “Joshua Nkomo” into “J. Nkomo” from input/output pairs. We’ll see how to define a tiny string-manipulation language, write an interpreter, and search the space of programs to find one that solves our toy problem.]]></summary></entry></feed>