<?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="https://matsmoll.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://matsmoll.github.io/" rel="alternate" type="text/html" /><updated>2026-07-05T10:46:01+00:00</updated><id>https://matsmoll.github.io/feed.xml</id><title type="html">Mats Eikeland Mollestad</title><subtitle>My personal tech blog</subtitle><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><entry><title type="html">I revived my old mac to a self-hosted cluster</title><link href="https://matsmoll.github.io/posts/revived-my-old-mac-to-a-self-hosted-cluster" rel="alternate" type="text/html" title="I revived my old mac to a self-hosted cluster" /><published>2026-07-05T00:00:00+00:00</published><updated>2026-07-05T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/self-hosting</id><content type="html" xml:base="https://matsmoll.github.io/posts/revived-my-old-mac-to-a-self-hosted-cluster"><![CDATA[<h2 id="motivation-beyond-the-dust">Motivation: Beyond the Dust</h2>

<p>As a tech enthusiast, I have a habit of accumulating “retired” hardware. Over the years, several iPhones and Macs have ended up in drawers, simply collecting dust. I wanted to change that—not just to give them a second life, but to reclaim my digital sovereignty.</p>

<p>My goal wasn’t just to build a server; it was to build a private ecosystem. I wanted to host my own personal ChatGPT interface (via Open WebUI) and my own private “Google Drive/Dropbox” (via OpenCloud). I wanted my data to live on my hardware, under my control, rather than in a corporate cloud.</p>

<p>I looked at a 2017 MacBook Pro sitting in my drawer. It still ran, even if the keyboard was a bit temperamental. I thought: <em>Why not turn this into a “free” Kubernetes cluster?</em></p>

<p>So, the journey began.</p>

<hr />

<h2 id="the-setup-from-silicon-to-kubernetes">The Setup: From Silicon to Kubernetes</h2>

<p>The first step was the basics. I installed Ubuntu 24.04 Desktop, using an old Raspberry Pi SD card to handle the BIOS installation since I didn’t have a spare USB drive handy. It was a slow start, but we were moving.</p>

<p>The first real hurdle was the hardware itself—specifically, getting the Broadcom Wi-Fi drivers to behave. After a frustrating session of trying to modify official driver source code with Claude’s help, I eventually found a script that actually worked: <a href="https://gist.github.com/torresashjian/e97d954c7f1554b6a017f07d69a66374">https://gist.github.com/torresashjian/e97d954c7f1554b6a017f07d69a66374</a>. I had to ensure this script runs on every restart, so as long as the Mac is powered, the Wi-Fi stays up.</p>

<p>Once I was online, I wanted a setup that felt modern. I enabled SSH for remote management, but for the actual updates, I wanted a “GitOps” workflow. I deployed <strong>k3s</strong> for lightweight orchestration and connected it to <strong>Flux CD</strong>. This meant that instead of manual configuration, Flux would pull directly from my <strong>GitLab</strong> repository and automatically apply any changes to the cluster.</p>

<h3 id="the-genius-plan-and-the-cg-nat-reality-check">The “Genius” Plan (and the CG-NAT Reality Check)</h3>

<p>Once the core system was humming, I decided to get “clever.”</p>

<p>I wanted to host my services so they were accessible from anywhere, but I didn’t want to pay my ISP for a static IP. <em>I’ll be smart about this,</em> I thought. <em>I’ll just write a simple Python script that checks my public IP every 15 minutes and updates my domain’s A records via an API. No static IP? No problem!</em></p>

<p>I even set up a <code class="language-plaintext highlighter-rouge">takk</code> job to automate this. I felt like a networking wizard.</p>

<p><strong>I was wrong.</strong></p>

<p>As it turns out, my network provider was using <strong>CG-NAT</strong> (Carrier-Grade NAT). My “clever” script was perfectly updating my domain to point to an IP address that didn’t actually lead back to my house. I was essentially shouting into a void.</p>

<p>Thankfully, a quick, slightly humbling phone call to my network provider solved it—they opened up a port for me, finally allowing the outside world to actually reach my MacBook.</p>

<h3 id="the-pivot-to-public-why-https-matters">The Pivot to Public: Why HTTPS Matters</h3>

<p>Now, I could have theoretically kept everything on my local network and accessed it via a VPN. However, I ran into a practical roadblock: <strong>OpenCloud requires HTTPS</strong> to function properly.</p>

<p>To get real SSL certificates and a professional setup, I needed a proper public endpoint. This led me to <strong>Domeneshop.no</strong>. They are a fantastic local DNS provider that not only offers a great API for my IP-update script but also integrates beautifully with Let’s Encrypt through a webhook adaptor. This meant that once my DNS was pointing to my host, the cluster could automatically handle SSL certificates, making the whole experience seamless and secure.</p>

<h3 id="managing-the-chaos-with-takk">Managing the Chaos with Takk</h3>

<p>With the network and DNS stable, I started deploying the “big” services: <strong>OpenCloud</strong> for my files and <strong>Open WebUI</strong> for my AI.</p>

<p>However, as I added more services—requiring Postgres databases, Redis caches, MinIO storage, and complex SSL certificates—the Kubernetes YAML files started to become a nightmare. I found myself constantly fighting with incorrect service names, misconfigured environment variables, and metadata mismatches.</p>

<p>This is where the project shifted from “playing with k8s” to “building a professional system.” To manage this complexity, I turned to <strong>takk</strong>—an infrastructure-as-code framework that I maintain.</p>

<p>Instead of writing hundreds of lines of brittle YAML, I define my infrastructure in Python. <strong>takk</strong> handles the heavy lifting: it generates the k8s configs, ensures all secrets and environment variables are consistent across services, and manages the relationships between third-party resources like databases and the apps that need them.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Annotated</span>
<span class="kn">from</span> <span class="nn">pydantic</span> <span class="kn">import</span> <span class="n">AnyUrl</span><span class="p">,</span> <span class="n">Field</span><span class="p">,</span> <span class="n">PostgresDsn</span>
<span class="kn">from</span> <span class="nn">pydantic_settings</span> <span class="kn">import</span> <span class="n">BaseSettings</span>
<span class="kn">from</span> <span class="nn">takk</span> <span class="kn">import</span> <span class="n">Project</span><span class="p">,</span> <span class="n">NetworkApp</span><span class="p">,</span> <span class="n">ServiceUrl</span>
<span class="kn">from</span> <span class="nn">takk.secrets</span> <span class="kn">import</span> <span class="n">PostgresHost</span><span class="p">,</span> <span class="n">PostgresName</span><span class="p">,</span> <span class="n">PostgresPassword</span><span class="p">,</span> <span class="n">PostgresUsername</span><span class="p">,</span> <span class="n">S3AccessKey</span><span class="p">,</span> <span class="n">S3BucketName</span><span class="p">,</span> <span class="n">S3Endpoint</span><span class="p">,</span> <span class="n">S3RegionName</span><span class="p">,</span> <span class="n">S3SecretKey</span>

<span class="k">class</span> <span class="nc">OpenWebUISettings</span><span class="p">(</span><span class="n">BaseSettings</span><span class="p">):</span>
    <span class="n">database_url</span><span class="p">:</span> <span class="n">PostgresDsn</span>
    <span class="n">s3_bucket_name</span><span class="p">:</span> <span class="n">S3BucketName</span>
    <span class="n">s3_region_name</span><span class="p">:</span> <span class="n">S3RegionName</span>
    <span class="n">s3_endpoint_url</span><span class="p">:</span> <span class="n">S3Endpoint</span>
    <span class="n">s3_secret_access_key</span><span class="p">:</span> <span class="n">S3SecretKey</span>
    <span class="n">s3_access_key</span><span class="p">:</span> <span class="n">S3AccessKey</span>
    <span class="c1"># Currently using Scaleway for the heavy lifting, 
</span>    <span class="c1"># but this architecture makes it trivial to point to a local LLM later.
</span>    <span class="n">openai_api_base_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">"https://api.scaleway.ai/v1"</span>
    <span class="n">openai_api_key</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">"... "</span>

<span class="n">project</span> <span class="o">=</span> <span class="n">Project</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"infra"</span><span class="p">,</span>
    <span class="n">chat</span><span class="o">=</span><span class="n">NetworkApp</span><span class="p">(</span>
        <span class="n">docker_image</span><span class="o">=</span><span class="s">"ghcr.io/open-webui/open-webui:v0.8.6"</span><span class="p">,</span>
        <span class="n">port</span><span class="o">=</span><span class="mi">8080</span><span class="p">,</span>
        <span class="n">settings</span><span class="o">=</span><span class="p">[</span><span class="n">OpenWebUISettings</span><span class="p">]</span>
    <span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<h3 id="the-aha-moment-connecting-my-brain-to-my-ai">The “Aha!” Moment: Connecting my Brain to my AI</h3>

<p>With the infrastructure stable, I hit the most rewarding part of the project.</p>

<p>I had my files in OpenCloud and my AI in Open WebUI, but they were living in separate worlds. My chat interface could talk to me, but it couldn’t “see” my data. I couldn’t ask it, <em>“What were my notes on the project from yesterday?”</em></p>

<p>I needed a bridge. Since there wasn’t a standard way to expose OpenCloud files to an LLM, I built one using <strong>FastAPI</strong>. I created a custom toolset that exposed my Obsidian paths, tags, and backlinks via an API.</p>

<p>Suddenly, the magic happened. I could ask my self-hosted AI:</p>
<blockquote>
  <p><em>“List out a few items with the ‘Papers’ tag.”</em></p>
</blockquote>

<p>Because I was using <strong>takk</strong>, adding this new FastAPI “bridge” to my cluster was trivial. I just added it to my <code class="language-plaintext highlighter-rouge">Project</code> config, and the cluster handled the rest. I even extended this to <strong>Neovim</strong>, allowing me to get inline AI completions in my terminal that could actually “read” my local files.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">project</span> <span class="o">=</span> <span class="n">Project</span><span class="p">(</span>
    <span class="c1"># ... existing config
</span>    <span class="n">obsidian_tools</span><span class="o">=</span><span class="n">FastAPIApp</span><span class="p">(</span>
        <span class="n">app</span><span class="o">=</span><span class="s">"src.takk_k8s.obsidian_tools:app"</span><span class="p">,</span>
        <span class="n">settings</span><span class="o">=</span><span class="p">[</span><span class="n">ObsidianToolsSettings</span><span class="p">],</span>
        <span class="n">compute</span><span class="o">=</span><span class="n">Compute</span><span class="p">(</span><span class="n">mvcpu_limit</span><span class="o">=</span><span class="mi">250</span><span class="p">,</span> <span class="n">mb_memory_limit</span><span class="o">=</span><span class="mi">256</span><span class="p">)</span>
    <span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<h3 id="scaling-and-staying-sane">Scaling and Staying Sane</h3>

<p>Once the “brain” was connected, I realized a cluster is only useful if it stays online. I didn’t want to find out my setup was broken only when I tried to use it.</p>

<p>I used the same logic to add <strong>MCP (Model Context Protocol)</strong> servers to monitor my cluster’s health and added Prometheus-based <strong>Alerting</strong>. Now, if a service enters a crash loop or a job fails, I get an email immediately. Everything—from the network to the alerts—is defined in code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">project</span> <span class="o">=</span> <span class="n">Project</span><span class="p">(</span>
    <span class="c1"># ... existing config
</span>    <span class="n">alerts</span><span class="o">=</span><span class="p">[</span>
        <span class="n">Alert</span><span class="p">(</span>
            <span class="n">name</span><span class="o">=</span><span class="s">"CrashLoop"</span><span class="p">,</span>
            <span class="n">query</span><span class="o">=</span><span class="s">"increase(kube_pod_container_status_restarts_total[1h]) &gt; 2"</span><span class="p">,</span>
            <span class="n">notify</span><span class="o">=</span><span class="p">[</span><span class="n">mats</span><span class="p">],</span>
            <span class="n">severity</span><span class="o">=</span><span class="s">"critical"</span>
        <span class="p">),</span>
        <span class="n">Alert</span><span class="p">(</span>
            <span class="n">name</span><span class="o">=</span><span class="s">"FailingJobs"</span><span class="p">,</span>
            <span class="n">query</span><span class="o">=</span><span class="s">"..."</span><span class="p">,</span>
            <span class="n">severity</span><span class="o">=</span><span class="s">"critical"</span><span class="p">,</span>
            <span class="n">notify</span><span class="o">=</span><span class="p">[</span><span class="n">mats</span><span class="p">]</span>
        <span class="p">),</span>
    <span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<h2 id="network-architecture">Network Architecture</h2>

<pre><code class="language-mermaid">graph TD
    subgraph "External"
        User[User Devices: Laptop/Mobile]
        DNS[Domeneshop DNS]
        LE[Let's Encrypt]
    end

    subgraph "Home Network"
        ISP[ISP / Open Port]
        Mac[MacBook Pro: k3s Cluster]
        PI[Pi-hole]
        OC[OpenCloud]
        OW[OpenWebUI]
        FA[FastAPI: Obsidian Tools]
    end

    User -- "Public URL" --&gt; DNS
    DNS -- "Resolves to" --&gt; ISP
    ISP -- "Routes to" --&gt; Mac
    LE -- "Certificates" --&gt; Mac
    Mac -- "Local DNS" --&gt; PI
    Mac -- "Host Services" --&gt; OC
    Mac -- "Host AI Interface" --&gt; OW
    OW -- "API Calls" --&gt; FA
    FA -- "Reads" --&gt; OC
    OC -- "Syncs Files" --&gt; User
</code></pre>

<h2 id="future-horizons">Future Horizons</h2>

<p>What started as a way to clear out a desk drawer has turned into a fully functioning, self-sovereign digital headquarters. I own my data, I own my AI, and I own the code that runs it all.</p>

<p>The next steps?</p>
<ol>
  <li><strong>Heavy Lifting:</strong> Moving from Scaleway to a dedicated machine with a GPU for fully local, high-speed LLMs (Gemma 4, etc.).</li>
  <li><strong>Self-Healing:</strong> Integrating a coding agent that can monitor the logs and suggest (or apply) fixes to the cluster itself.</li>
  <li><strong>Hardened Backups:</strong> Moving from “syncing” to a professional backup strategy using tools like Velero.</li>
</ol>

<p><strong>What do you think? If you’ve ever tried to host your own AI or struggled with the “joy” of CG-NAT, I’d love to hear your thoughts in the comments!</strong></p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="Self-hosting" /><category term="AI Setup" /><category term="Obsidian" /><summary type="html"><![CDATA[Motivation: Beyond the Dust]]></summary></entry><entry><title type="html">Smoke Testing for ML Pipelines</title><link href="https://matsmoll.github.io/posts/smoke-testing-ml-pipelines" rel="alternate" type="text/html" title="Smoke Testing for ML Pipelines" /><published>2025-08-18T00:00:00+00:00</published><updated>2025-08-18T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/smoketesting-ml-pipelines</id><content type="html" xml:base="https://matsmoll.github.io/posts/smoke-testing-ml-pipelines"><![CDATA[<p>When working on machine learning pipelines, most bugs aren’t about bad models — they’re about broken plumbing.</p>

<p>They’ll be things like:</p>

<ul>
  <li>A preprocessing step crashing because a column is missing.</li>
  <li>Data arriving in a slightly different format than your code expects.</li>
  <li>The input schema for the model doesn’t match the data provided at inference.</li>
</ul>

<p>These problems aren’t glamorous, but they’re the ones that break production the fastest.
The fix? Add smoke tests that run the pipeline end-to-end with tiny, synthetic datasets.</p>

<h2 id="why-smoke-tests-work">Why smoke tests work</h2>
<p>The idea is simple: run your pipeline end-to-end using synthetic data that doesn’t need big hardware. The goal isn’t to prove the model is good — it’s to prove the pipeline still runs and still respects its expected input and output formats.</p>

<p>With this approach, you can catch schema changes, broken preprocessing logic, or missing dependencies before you commit to running a full training job.</p>

<p>A smoke test saves you from an 8-hour training run that crashes on a missing column. Rather it would catch it in seconds.</p>

<p>So how do we create these datasets?</p>

<h2 id="generating-synthetic-data">Generating synthetic data</h2>
<p>You can create synthetic test data in two main ways:</p>

<ul>
  <li>Fully randomised data — great for checking that schemas match and code runs, without caring about meaning.</li>
  <li>Partially controlled data — lets you embed known patterns so you can confirm the model can still detect them.</li>
</ul>

<p>If you’re working with a framework that defines your data contracts or feature views, you can often generate random data directly from those definitions. Here is an example using <a href="https://github.com/MatsMoll/aligned">aligned</a> and the wine quality dataset:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">data_contract</span><span class="p">()</span>
<span class="k">class</span> <span class="nc">WineQuality</span><span class="p">:</span>
    <span class="n">fixed_acidity</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">volatile_acidity</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">citric_acid</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">residual_sugar</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">chlorides</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">free_sulfur_dioxide</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">total_sulfur_dioxide</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">density</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">pH</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">().</span><span class="n">bounded_between</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">14</span><span class="p">)</span>                  
    <span class="n">sulphates</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">()</span>
    <span class="n">alcohol</span> <span class="o">=</span> <span class="n">Float64</span><span class="p">().</span><span class="n">bounded_between</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">20</span><span class="p">)</span>
           
    <span class="n">quality</span> <span class="o">=</span> <span class="n">Int64</span><span class="p">().</span><span class="n">bounded_between</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
    
<span class="n">df</span> <span class="o">=</span> <span class="k">await</span> <span class="n">WineQuality</span><span class="p">.</span><span class="n">n_examples</span><span class="p">(</span><span class="mi">100</span><span class="p">).</span><span class="n">to_polars</span><span class="p">()</span>
</code></pre></div></div>

<p>This contract enforces valid value ranges, or infer defaults based on the data types — which means we can safely auto-fill any column we don’t explicitly set.</p>

<p>Random data is good, but sometimes you need controlled patterns.</p>

<h2 id="defining-a-known-pattern">Defining a known pattern</h2>
<p>Let’s make a known pattern with the following rules:</p>
<ul>
  <li>Wines with alcohol &gt; 12 get quality = 8</li>
  <li>Wines with alcohol &lt;= 12 get quality = 4</li>
</ul>

<p>We’ll specify just those two columns, and let the rest be random within their contract bounds:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">aligned.source</span> <span class="kn">import</span> <span class="n">RandomDataSource</span>

<span class="c1"># Defining the pattern
</span><span class="n">patterned_source</span> <span class="o">=</span> <span class="n">RandomDataSource</span><span class="p">.</span><span class="n">with_values</span><span class="p">({</span>
    <span class="s">"alcohol"</span><span class="p">:</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">13</span><span class="p">,</span> <span class="mi">15</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="mi">14</span><span class="p">],</span>
    <span class="s">"quality"</span><span class="p">:</span> <span class="p">[</span><span class="mi">4</span><span class="p">,</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">8</span><span class="p">]</span>
<span class="p">})</span>

<span class="n">store</span> <span class="o">=</span> <span class="k">await</span> <span class="n">ContractStore</span><span class="p">.</span><span class="n">from_dir</span><span class="p">(</span><span class="s">"."</span><span class="p">)</span>

<span class="c1"># Switching to the new data source
</span><span class="n">store</span> <span class="o">=</span> <span class="n">store</span><span class="p">.</span><span class="n">update_source_for</span><span class="p">(</span>
    <span class="n">WineQuality</span><span class="p">,</span>
    <span class="n">patterned_source</span>
<span class="p">)</span>
</code></pre></div></div>
<p>Because the other columns in WineQuality aren’t defined here, they’ll be filled in automatically with valid random values. However, all rows will have the same random value, creating a strong deterministic relationship between the alcohol and quality columns. With that setup, we can now load the dataset and train a model.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">sklearn.ensemble</span> <span class="kn">import</span> <span class="n">RandomForestClassifier</span>

<span class="n">df</span> <span class="o">=</span> <span class="k">await</span> <span class="n">store</span><span class="p">.</span><span class="n">contract</span><span class="p">(</span><span class="n">WineQuality</span><span class="p">).</span><span class="nb">all</span><span class="p">().</span><span class="n">to_pandas</span><span class="p">()</span>

<span class="n">X</span> <span class="o">=</span> <span class="n">df</span><span class="p">.</span><span class="n">drop</span><span class="p">(</span><span class="s">"quality"</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">"quality"</span><span class="p">]</span>

<span class="n">model</span> <span class="o">=</span> <span class="n">RandomForestClassifier</span><span class="p">().</span><span class="n">fit</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
</code></pre></div></div>
<p>Next, let’s validate the pipeline by predicting on a controlled test-set.</p>

<h2 id="generating-prediction-samples">Generating prediction samples</h2>
<p>Now that we have a model, let’s make some predictions to test and validate that the model picked up on the known pattern.</p>

<p>Thankfully we can generate new prediction samples by defining only the features that matter for your pattern — here, alcohol — and letting everything else be random:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pred_df</span> <span class="o">=</span> <span class="k">await</span> <span class="n">store</span><span class="p">.</span><span class="n">contract</span><span class="p">(</span><span class="n">WineQuality</span><span class="p">).</span><span class="n">select</span><span class="p">(</span><span class="n">X</span><span class="p">.</span><span class="n">columns</span><span class="p">).</span><span class="n">features_for</span><span class="p">({</span>
    <span class="s">"alcohol"</span><span class="p">:</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">14</span><span class="p">]</span>
<span class="p">}).</span><span class="n">to_polars</span><span class="p">()</span>

<span class="n">preds</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">pred_df</span><span class="p">)</span>

<span class="k">assert</span> <span class="n">preds</span><span class="p">.</span><span class="n">to_list</span><span class="p">()</span> <span class="o">==</span> <span class="p">[</span><span class="mi">4</span><span class="p">,</span> <span class="mi">8</span><span class="p">]</span>
</code></pre></div></div>

<p>With this, we can finally check that our pipeline loads the correct data and trains a model that finds the known pattern.
Furthermore, if you connect a proper model registry to this pipeline could you also test that those integrations work as expected.</p>

<h2 id="conclusion">Conclusion</h2>
<p>Smoke tests won’t guarantee that your model is cutting-edge, but they will guarantee that your pipeline is alive and dependable. By mixing randomized data for schema validation with controlled patterns for behavioral checks, you catch the kinds of issues that cause real outages. Missing columns, broken preprocessing, or silent feature drops.</p>

<p>You can even extend these tests beyond data and training by wiring in a model registry. Verifying that a trained model can be stored, retrieved, and reloaded successfully gives you end-to-end confidence — from raw data all the way to deployable artifacts.</p>

<p>These checks are fast, cheap, and easy to automate. Making them perfect for CI / CD. Enabling us to check every code change in seconds, long before you commit resources to a full training run. The result is fewer surprises, faster iteration, and more confidence that your pipeline is doing what you expect. So when it’s time to improve accuracy, you can focus on the model, not firefighting infrastructure.</p>

<p>For more details on using aligned, check out the <a href="https://github.com/MatsMoll/aligned">project on GitHub</a> or its <a href="https://www.aligned.codes/">documentation</a>.</p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="ML Testing" /><category term="CI/CD" /><summary type="html"><![CDATA[When working on machine learning pipelines, most bugs aren’t about bad models — they’re about broken plumbing.]]></summary></entry><entry><title type="html">How I created an end-to-end ML platform for a personal banking AI</title><link href="https://matsmoll.github.io/posts/end-to-end-banking-ai" rel="alternate" type="text/html" title="How I created an end-to-end ML platform for a personal banking AI" /><published>2024-04-06T00:00:00+00:00</published><updated>2024-04-06T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/end-to-end-ml</id><content type="html" xml:base="https://matsmoll.github.io/posts/end-to-end-banking-ai"><![CDATA[<p>This Christmas I wanted to set my-self a fun challenge. I wanted to create an end-to-end AI product, with model serving, with performance monitoring, with a data catalog, with propper data engineering, with orchestration, with an iOS front-end, and running only on local compute. Not to much to ask, right?</p>

<h2 id="the-ml-problem">The ML Problem</h2>
<p>However, to implement such a project was an ML related problem needed. Therefore, I thought it would be fun to create an ML model that predicted my expenses for the next month, but grouped by categories like transportation, groceries, sports, etc.</p>

<p>Potentially not the most useful model, as I know fairly well how my expenses will be because of a fairly good banking app. However, since I know the data well, will it be easier to figure out when the model is way off, which makes it the perfect use-case for an end-to-end ML platform.</p>

<p>However I started looking into the API of my bank, and saw that I was lacking the data I needed…
More precisely, I was lacking some of the data that told me if it was spent on transactions, groceries, etc. 
However, I thankfully had it for all my in-person VISA card transactions.</p>

<p>Therefore, this post will go info how I created a model that classified my bank transactions, while ensuring high quality datasets, how I made it easy to maintain models that depend on each other, and how I monitored the performance in production.</p>

<h2 id="the-source-data">The Source Data</h2>
<p>Before I can start doing anything really practical, would I need to get my hand on some real data. Or at least understand how the data would behave to some degree.</p>

<p>This lead to write a simple API integration with my bank that loaded my transaction data given a time period. E.g. from 1 January 2023 to 1 January 2024, or by providing a number of records to load.</p>

<p>I also defined the schema of the response I expected, which you can see bellow. We will come back to the package used to define the schema, as it is not your ordinary <code class="language-plaintext highlighter-rouge">pydantic</code> model, and for a very good reason.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Transaction</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    
    <span class="n">amount</span> <span class="o">=</span> <span class="n">Float</span><span class="p">()</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>
    
    <span class="n">user_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">description</span><span class="p">(</span>
        <span class="s">"Only a hard coded value, as it is only my own user"</span>
    <span class="p">)</span>
    <span class="n">account_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>
    
    <span class="n">accounting_date</span> <span class="o">=</span> <span class="n">Timestamp</span><span class="p">()</span>
    <span class="n">interest_date</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>


    <span class="n">transaction_type</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>

    <span class="n">card_details</span> <span class="o">=</span> <span class="n">Json</span><span class="p">().</span><span class="n">is_optional</span><span class="p">().</span><span class="n">description</span><span class="p">(</span>
        <span class="s">"Is only set where transaction_type = VISA VARE"</span>
        <span class="s">"Contains a lot of interesting details tho"</span>
        <span class="s">"E.g. merchant category, location, etc."</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>However, rather then going for an ordinary SQL database, did I go for more of a data lake solution. Therefore, all of the data would be loaded from an API, but stored in a Parquet file locally.
So how would I manage the data? This is where <code class="language-plaintext highlighter-rouge">aligned</code> comes into play. 
Aligned is a package used to manage data for ML applications, and is also the package used to define the schema for my transaction data.</p>

<p>However, in addition to defining schemas, can we add different types of sources. Like a load source, materialised sources, and stream sources. But also define how often we expect them to be updated. Which can be done with the following:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">feature_view</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transactions"</span><span class="p">,</span>
    <span class="n">source</span><span class="o">=</span><span class="n">api_source</span><span class="p">,</span>
    <span class="n">materialized_source</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">parquet_at</span><span class="p">(</span>
        <span class="s">"source_data/transactions.parquet"</span>
    <span class="p">),</span>
    <span class="n">acceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">),</span>
    <span class="n">unacceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">6</span><span class="p">),</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Transaction</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="n">amount</span> <span class="o">=</span> <span class="n">Float</span><span class="p">()</span>
    <span class="p">...</span>
</code></pre></div></div>

<h3 id="ml-data-management">ML Data Management</h3>
<p>Now that I finally had the source data loaded was it time to create the model.</p>

<p>This ment a few things:</p>
<ul>
  <li>I needed to transform the raw data to extract the merchant category out from from the json data.</li>
  <li>I needed to create a dataset where the merchant category is the ground truth.</li>
  <li>I needed to define which inputs to use for the model.</li>
</ul>

<p>Thankfully was the first step very simple. It was done by adding a new property to the schema, but that used the <code class="language-plaintext highlighter-rouge">card_details</code> as the source. Therefore, creating a “type-safe” computation, with data lineage and a lot of goodies.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">feature_view</span><span class="p">(...)</span>
<span class="k">class</span> <span class="nc">Transaction</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="p">...</span>
    
    <span class="n">card_details</span> <span class="o">=</span> <span class="n">Json</span><span class="p">().</span><span class="n">is_optional</span><span class="p">()</span>
    <span class="n">merchant_name</span> <span class="o">=</span> <span class="n">card_details</span><span class="p">.</span><span class="n">field</span><span class="p">(</span>
        <span class="s">"merchantName"</span><span class="p">,</span> 
        <span class="n">as_type</span><span class="o">=</span><span class="n">String</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>When it comes to the second part about defining the ground truth do <code class="language-plaintext highlighter-rouge">aligned</code> have an elegant method. <code class="language-plaintext highlighter-rouge">aligned</code> introduces the concept of model contracts which tell us what a model is predicting, their ground truth, and a lot of other related metadata. As a result can we use a similar syntax as the transformation to define that the <code class="language-plaintext highlighter-rouge">merchant_name</code> should be the ground truth and the predicted value.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">transaction</span> <span class="o">=</span> <span class="n">Transaction</span><span class="p">()</span>

<span class="o">@</span><span class="n">model_contract</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transaction_category"</span><span class="p">,</span>
    <span class="n">input_features</span><span class="o">=</span><span class="p">[...],</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">TransactionCategory</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="n">predicted_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>
    
    <span class="n">predicted_category</span> <span class="o">=</span> <span class="p">(</span><span class="n">transaction</span><span class="p">.</span><span class="n">merchant_name</span>
        <span class="p">.</span><span class="n">as_classification_label</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>By adding the <code class="language-plaintext highlighter-rouge">as_classification_label</code> will we tell <code class="language-plaintext highlighter-rouge">aligned</code> where to find the ground truth, what we are predicting, and what the model should name the prediction, as it is set to the <code class="language-plaintext highlighter-rouge">predicted_category</code> field.</p>

<h3 id="create-a-training-dataset">Create a Training Dataset</h3>

<p>In order to train a model would I need a dataset, and thanks to the defined relationship in the last section will it be easy to create. The only thing I need to define is who I want to create a training dataset for, and in my case do I want it for all transactions where we have a ground truth value. However, the ground truth check will be done automatically, so all we need to care about will be for who to load our data for. Therefore, this can be loaded with the following.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">entities</span> <span class="o">=</span> <span class="n">Transactions</span><span class="p">.</span><span class="n">query</span><span class="p">().</span><span class="n">all_columns</span><span class="p">(</span><span class="n">limit</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
</code></pre></div></div>

<p>And to create a dataset can we do the following.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">store</span> <span class="o">=</span> <span class="k">await</span> <span class="n">FeatureStore</span><span class="p">.</span><span class="n">from_dir</span><span class="p">(</span><span class="s">"."</span><span class="p">)</span>

<span class="n">datasets</span> <span class="o">=</span> <span class="p">(</span><span class="n">store</span><span class="p">.</span><span class="n">model</span><span class="p">(</span><span class="s">"transaction_category"</span><span class="p">)</span>
    <span class="p">.</span><span class="n">with_labels</span><span class="p">()</span>
    <span class="p">.</span><span class="n">features_for</span><span class="p">(</span><span class="n">entities</span><span class="p">)</span>
    <span class="p">.</span><span class="n">train_test_validate</span><span class="p">(</span>
        <span class="n">train_size</span><span class="o">=</span><span class="mf">0.7</span><span class="p">,</span>
        <span class="n">validate_size</span><span class="o">=</span><span class="mf">0.15</span>
    <span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This will make sure of a few things.</p>
<ul>
  <li>We have a ground truth in each row.</li>
  <li>We do not train on data that was created after we would have made the prediction. Also known as point-in-time correct data.</li>
  <li>Making sure we do not have data leakage in time, by splitting the dataset based on event timestamps. If there exist no will it do a random split.</li>
</ul>

<p>From here we can load different datasets by accessing the <code class="language-plaintext highlighter-rouge">train</code>, <code class="language-plaintext highlighter-rouge">test</code>, or <code class="language-plaintext highlighter-rouge">validate</code> property.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">train_data</span> <span class="o">=</span> <span class="k">await</span> <span class="n">datasets</span><span class="p">.</span><span class="n">train</span><span class="p">.</span><span class="n">to_polars</span><span class="p">()</span>

<span class="n">model</span> <span class="o">=</span> <span class="n">LogicticRegression</span><span class="p">()</span>
<span class="n">model</span><span class="p">.</span><span class="n">train</span><span class="p">(</span>
    <span class="n">X</span><span class="o">=</span><span class="n">train_data</span><span class="p">.</span><span class="nb">input</span><span class="p">,</span> 
    <span class="n">y</span><span class="o">=</span><span class="n">train_data</span><span class="p">.</span><span class="n">labels</span>
<span class="p">)</span>

<span class="k">for</span> <span class="n">dataset</span> <span class="ow">in</span> <span class="p">[</span><span class="n">datasets</span><span class="p">.</span><span class="n">test</span><span class="p">,</span> <span class="n">datasets</span><span class="p">.</span><span class="n">validate</span><span class="p">]:</span>
    <span class="n">eval_data</span> <span class="o">=</span> <span class="n">dataset</span><span class="p">.</span><span class="n">to_polars</span><span class="p">()</span>
    <span class="n">preds</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">eval_data</span><span class="p">.</span><span class="nb">input</span><span class="p">)</span>
    
    <span class="n">accuracy</span> <span class="o">=</span> <span class="p">(</span><span class="n">preds</span> <span class="o">==</span> <span class="n">eval_data</span><span class="p">.</span><span class="n">labels</span><span class="p">).</span><span class="n">mean</span><span class="p">()</span>
</code></pre></div></div>

<p>And that’s it, I finally had my model which I then stored in a MLFlow registry. I skipped the MLFlow implementation, to keep it simple.</p>

<h3 id="use-the-model">Use the model</h3>
<p>There are a wide range of ways to deploy a model. A common way is use a MLFlow server, which can either be started locally or a data platform such as Databricks.</p>

<p>This is where aligned helps even more, as it makes it possible to describe where, and how to use a model.
Therefore, we can define that we have an MLFlow model, served at a specific url. Or for simplicity sake, since the model has a low memory footprint can I use it in memory. Therefore, I modified the model contract to the following</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">model_contract</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transaction_category"</span><span class="p">,</span>
    <span class="n">input_features</span><span class="o">=</span><span class="p">[...],</span>
    <span class="n">exposed_model</span><span class="o">=</span><span class="n">ExposedModel</span><span class="p">.</span><span class="n">in_memory_mlflow</span><span class="p">(</span>
        <span class="n">model_name</span><span class="o">=</span><span class="s">"transaction_category"</span><span class="p">,</span>
        <span class="n">model_alias</span><span class="o">=</span><span class="s">"Champion"</span><span class="p">,</span>

        <span class="n">prediction_column</span><span class="o">=</span><span class="s">"predicted_category"</span><span class="p">,</span>
        <span class="n">predicted_at_column</span><span class="o">=</span><span class="s">"predicted_at"</span><span class="p">,</span>
    <span class="p">),</span>
    <span class="n">output_source</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">parquet_at</span><span class="p">(</span>
        <span class="s">"transaction_category_preds.parquet"</span>
    <span class="p">)</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">TransactionCategory</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="n">predicted_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>
    
    <span class="n">predicted_category</span> <span class="o">=</span> <span class="p">(</span><span class="n">transaction</span><span class="p">.</span><span class="n">merchant_name</span>
        <span class="p">.</span><span class="n">as_classification_label</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>Therefore, enabling me to just define who to predict for, and the feature loading, interacting with a model in memory or through an API will be done for you, metadata will be added automatically.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">predictions</span> <span class="o">=</span> <span class="k">await</span> <span class="n">store</span><span class="p">.</span><span class="n">model</span><span class="p">(</span><span class="s">"transaction_category"</span><span class="p">).</span><span class="n">predict_over</span><span class="p">({</span>
    <span class="s">"transaction_id"</span><span class="p">:</span> <span class="p">[...]</span>
<span class="p">}).</span><span class="n">to_polars</span><span class="p">()</span>
</code></pre></div></div>

<p>Or if I want to store them in the <code class="language-plaintext highlighter-rouge">output_source</code>, I could do it with the following.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="n">store</span><span class="p">.</span><span class="n">model</span><span class="p">(</span><span class="s">"transaction_category"</span><span class="p">).</span><span class="n">predict_over</span><span class="p">({</span>
    <span class="s">"transaction_id"</span><span class="p">:</span> <span class="p">[...]</span>
<span class="p">}).</span><span class="n">upsert_into_output_source</span><span class="p">()</span>
</code></pre></div></div>

<h3 id="the-model-input">The model input</h3>

<p>Until now have I skipped one crucial part. I have not talked about the input I used to the classification model.</p>

<p>Since I know the transaction text description would probably be the best feature, did I land on using an embedding model, and use the embedding vector as the inputs.</p>

<p>Thankfully, <code class="language-plaintext highlighter-rouge">alinged</code> have an easy integration to setup a complete model contract with Ollama integration.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">TransactionEmbedding</span> <span class="o">=</span> <span class="n">ollama_embedding_contract</span><span class="p">(</span>
    <span class="n">contract_name</span><span class="o">=</span><span class="s">"transaction_embedding"</span><span class="p">,</span>
    <span class="n">text</span><span class="o">=</span><span class="n">transaction</span><span class="p">.</span><span class="n">text</span><span class="p">,</span>
    <span class="n">endpoint</span><span class="o">=</span><span class="s">"http://host.docker.internal:11434"</span><span class="p">,</span>
    <span class="n">model</span><span class="o">=</span><span class="s">"mistral:latest"</span><span class="p">,</span>
    <span class="n">entities</span><span class="o">=</span><span class="n">transaction</span><span class="p">.</span><span class="n">transaction_id</span><span class="p">,</span>
    <span class="n">output_source</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">parquet_at</span><span class="p">(</span>
        <span class="s">"transaction_embedding.parquet"</span>
    <span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>From here the input can be sent into the original transaction model.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">transaction_embedding</span> <span class="o">=</span> <span class="n">TransactionEmbedding</span><span class="p">()</span>
<span class="o">@</span><span class="n">model_contract</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transaction_category"</span><span class="p">,</span>
    <span class="n">input_features</span><span class="o">=</span><span class="p">[</span><span class="n">transaction_embedding</span><span class="p">.</span><span class="n">embedding</span><span class="p">],</span>
    <span class="p">...</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">TransactionCategory</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="n">predicted_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>
    
    <span class="n">predicted_category</span> <span class="o">=</span> <span class="p">(</span><span class="n">transaction</span><span class="p">.</span><span class="n">merchant_name</span>
        <span class="p">.</span><span class="n">as_classification_label</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>This will make it possible for <code class="language-plaintext highlighter-rouge">aligned</code> to ensure that we never break the transaction category model, by making breaking changes to the embedding model.</p>

<h3 id="evaluate-the-model">Evaluate the model</h3>

<p>Finally, I had a model to use, and I finally created an API and an iOS widget to send a request to predict over my new transactions each day. This ment I accumulated predictions all the time, and after some time I could finally evaluate it.</p>

<p>Thankfully, <code class="language-plaintext highlighter-rouge">aligned</code> knows where we store predictions, and where the ground truths are, and knows that it is a classification problem. Thereby making evaluation super easy.</p>

<p>Thereby, we can open the aligned UI and get confusion matrix, accuracy, precision, and recall metrics without doing anything.</p>

<p>Furthermore, we can add a model version variable to the model contract in order to compare different models against each other.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">model_contract</span><span class="p">(...)</span>
<span class="k">class</span> <span class="nc">TransactionCategory</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    <span class="n">predicted_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>
    <span class="n">model_version</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_model_version</span><span class="p">()</span>
    
    <span class="n">predicted_category</span> <span class="o">=</span> <span class="p">(</span><span class="n">transaction</span><span class="p">.</span><span class="n">merchant_name</span>
        <span class="p">.</span><span class="n">as_classification_label</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

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

<p>Building an end-to-end ML project have made it clearer the wide range of problems that ML offers. Furthermore, <code class="language-plaintext highlighter-rouge">aligned</code> makes it possible to get more done with less work, by moving the focus away from model first. And focusing on how the ML data flow will behave, and then say how to integrate the model.</p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="MLOps" /><category term="AI Products" /><summary type="html"><![CDATA[This Christmas I wanted to set my-self a fun challenge. I wanted to create an end-to-end AI product, with model serving, with performance monitoring, with a data catalog, with propper data engineering, with orchestration, with an iOS front-end, and running only on local compute. Not to much to ask, right?]]></summary></entry><entry><title type="html">The DBT of AI</title><link href="https://matsmoll.github.io/posts/the-dbt-of-ai" rel="alternate" type="text/html" title="The DBT of AI" /><published>2024-02-11T00:00:00+00:00</published><updated>2024-02-11T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/the-ai-of-dbt</id><content type="html" xml:base="https://matsmoll.github.io/posts/the-dbt-of-ai"><![CDATA[<p>AI is evolving rapidly with new models every day. However, it is easy to get lost in the hype of new models and forget how AI will be integrated at a system-wide level.</p>

<p>I missed such system-wide tooling, so I created <a href="https://github.com/MatsMoll/aligned">aligned</a> to make it easier to understand, develop, and evaluate AI and ML data products.</p>

<h2 id="my-annoyances">My Annoyances</h2>
<p>Before we delve into my goals with <code class="language-plaintext highlighter-rouge">aligned</code>, it is important to understand what I felt was missing in the current AI stack.</p>

<h3 id="code-completion">Code Completion</h3>
<p>One of the most surprising things about moving to the AI ecosystem was the lack of code completion and tooling to prevent bugs. This was especially clear when coming from a strictly typed language like Swift, where types need to be enforced, and the use of raw strings is seen as a code smell.</p>

<p>In contrast, the AI landscape was the clear opposite. Almost no types, and the types that were present were not enforced either. But also, almost all code was described using raw strings. For example, how <code class="language-plaintext highlighter-rouge">pandas</code> do transformations - <code class="language-plaintext highlighter-rouge">df["new"] = df["a"] + df["b"]</code>.</p>

<p>Why is this raw string usage so bad? It makes it almost impossible for linters to find semantic errors, like mistyped attributes or invalid datatype operations. But it also makes it very hard to provide code completion, and help the developer understand what is possible to do. Therefore, reducing the dependence on package tutorials and documentation.</p>

<h3 id="implicit-schemas">Implicit Schemas</h3>
<p>Continuing on the usage of strings, I often found that Python programs heavily relied on implicit schemas.</p>

<p>One such example would be <code class="language-plaintext highlighter-rouge">yaml</code> config files, and how they often get accessed through dictionaries.
Below is a fairly common training pipeline that I found in a Medium post.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span> <span class="o">=</span> <span class="n">load_config</span><span class="p">(</span><span class="s">"my_config.yaml"</span><span class="p">)</span>

<span class="c1"># load data
</span><span class="n">data</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="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span>
        <span class="n">config</span><span class="p">[</span><span class="s">"data_directory"</span><span class="p">],</span> 
        <span class="n">config</span><span class="p">[</span><span class="s">"data_name"</span><span class="p">]</span>
    <span class="p">)</span>
<span class="p">)</span>

<span class="c1"># drop id column
</span><span class="n">data</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="n">drop</span><span class="p">(</span><span class="n">config</span><span class="p">[</span><span class="s">"drop_columns"</span><span class="p">],</span> <span class="n">axis</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>

<span class="c1"># Define X (independent variables) and y (target variable)
</span><span class="n">X</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">drop</span><span class="p">(</span><span class="n">config</span><span class="p">[</span><span class="s">"target_name"</span><span class="p">],</span> <span class="mi">1</span><span class="p">))</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">config</span><span class="p">[</span><span class="s">"target_name"</span><span class="p">]])</span>

<span class="c1"># split data into train and test sets
</span><span class="n">X_train</span><span class="p">,</span> <span class="n">X_test</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">y_test</span> <span class="o">=</span> <span class="n">train_test_split</span><span class="p">(</span>
    <span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">test_size</span><span class="o">=</span><span class="n">config</span><span class="p">[</span><span class="s">"test_size"</span><span class="p">],</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span>
<span class="p">)</span>

<span class="c1"># call our classifier and fit it to our data
</span><span class="n">classifier</span> <span class="o">=</span> <span class="n">KNeighborsClassifier</span><span class="p">(</span>
    <span class="n">n_neighbors</span><span class="o">=</span><span class="n">config</span><span class="p">[</span><span class="s">"n_neighbors"</span><span class="p">],</span>
    <span class="n">weights</span><span class="o">=</span><span class="n">config</span><span class="p">[</span><span class="s">"weights"</span><span class="p">],</span>
    <span class="n">n_jobs</span><span class="o">=</span><span class="n">config</span><span class="p">[</span><span class="s">"n_jobs"</span><span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<p>If any of these keys are missing, or potentially in the incorrect data type, this pipeline can fail. For example, if an integer is loaded as a string.
Rather, I want these implicit schemas to be defined explicitly, making it possible to fail faster and make it clearer which information is available.
One example could be <a href="https://pypi.org/project/pydantic/"><code class="language-plaintext highlighter-rouge">pydantic</code></a>, and it shows how usefull explicit definition of schemas can be.</p>

<h3 id="implicit-logic">Implicit Logic</h3>
<p>Another issue I found was that getting a Birds Eye view of our AI products was hard.
We mostly documented our AI products manually, but it was often only showing the AI product, and not how it integrated into the system on a larger scale.
Furthermore, the technical documentation was either missing or out of date.</p>

<p>Therefore, questions such as “what are the goals of the model,” “who owns the model,” “which data are needed to run the model,” “what are we predicting,” “where do we store predictions,” “where can I run the model,” “how often do we expect the model to run,” “where do we store training datasets,” and so much more were left unanswered.</p>

<p>Often I found that a lot of these questions were described implicitly in code, but outdated in the docs.</p>

<h3 id="default-implementation">Default Implementation</h3>
<p>Implicit logic and implicit schemas are somewhat similar. However, defining both of them can at first seem like more of a pain than anything.
But defining the implicit schemas and logic explicitly can help derive default implementations.
Such as data validation, setting up data freshness checks, row duplication checks, model evaluation, model performance monitoring, and so much more.</p>

<p>Therefore, explicit schemas and logic do not only help with failing faster, but it also helps with implementing reasonable functionality in less time.</p>

<h3 id="inflexible-data-sources">Inflexible Data Sources</h3>
<p>Lastly, I was surprised at how impractical it was to work with data sources in AI.
We either created custom data wrappers, got locked into using one data warehouse, or we copied the data of interest to a local file.</p>

<p>However, we often wanted the possibility to combine our data warehouse with experimental sources, like local files or transactional databases, but this was either very convoluted or impossible to do.</p>

<h2 id="the-goals">The Goals</h2>
<p>As a result, I wanted to improve all of this.
Therefore, I wanted to create a tool that provides a good developer experience by offering code completion and catching errors earlier.
I wanted you to explicitly define your expectations, so that we can fail faster but also go further with less work.
I wanted</p>

<p>the technical documentation to be generated from code, rather than being dependent on us keeping it up to date.
And of course, I wanted to make it possible to mix and match data sources, so you can run experiments in less time.</p>

<h2 id="aligned">Aligned</h2>
<p>This is how <code class="language-plaintext highlighter-rouge">aligned</code> was created.
Now, if you are familiar with facts and dimensions, this should hopefully not be too unfamiliar.</p>

<p>At its core, <code class="language-plaintext highlighter-rouge">aligned</code> will introduce two concepts: <code class="language-plaintext highlighter-rouge">feature_view</code>s - similar to a dimension, and <code class="language-plaintext highlighter-rouge">model_contract</code>s.</p>

<h3 id="model-contract">Model Contract</h3>
<p>A model contract is crucial in <code class="language-plaintext highlighter-rouge">aligned</code>. This is where we define all the metadata for our model, such as intent, input features, and output.</p>

<p>To make this clearer, I will present a side project I created where I wanted to categorize my bank transactions.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">model_contract</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transaction_category"</span><span class="p">,</span>
    <span class="n">features</span><span class="o">=</span><span class="p">[</span>
        <span class="n">transaction</span><span class="p">.</span><span class="n">text_embedding</span>
    <span class="p">],</span>
    <span class="n">prediction_source</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">parquet_at</span><span class="p">(</span><span class="s">"pred_data/transaction_category_test.parquet"</span><span class="p">),</span>
    <span class="n">dataset_store</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">json_at</span><span class="p">(</span><span class="s">"datasets/transaction_category.json"</span><span class="p">),</span>
    <span class="n">exposed_at_url</span><span class="o">=</span><span class="s">"http://server:8000/openapi.json"</span><span class="p">,</span> <span class="c1"># An internal docker compose URL
</span>    <span class="n">acceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">2</span><span class="p">),</span>
    <span class="n">unacceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">4</span><span class="p">),</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">TransactionCategory</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
    
    <span class="n">predicted_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>
    
    <span class="n">model_version</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_model_version</span><span class="p">()</span>

    <span class="n">predicted_category</span> <span class="o">=</span> <span class="p">(</span>
        <span class="n">transaction</span><span class="p">.</span><span class="n">merchant_category</span>
            <span class="p">.</span><span class="n">as_classification_label</span><span class="p">()</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>So, what is happening here?
First of all, we define a model that will take an embedding as the input (<code class="language-plaintext highlighter-rouge">features</code>) and that the model will produce a <code class="language-plaintext highlighter-rouge">predicted_category</code> together with some other metadata, like <code class="language-plaintext highlighter-rouge">predicted_at</code> the <code class="language-plaintext highlighter-rouge">transaction_id</code> it predicted for, and the model used in its <code class="language-plaintext highlighter-rouge">model_version</code>.</p>

<p>Furthermore, we also define where our predictions are stored (<code class="language-plaintext highlighter-rouge">prediction_source</code>), where the model is exposed (<code class="language-plaintext highlighter-rouge">exposed_at_url</code>), and how often we expect it to predict (<code class="language-plaintext highlighter-rouge">acceptable_freshness</code>, <code class="language-plaintext highlighter-rouge">unacceptable_freshness</code>), and where we store the train, test, validate sets (<code class="language-plaintext highlighter-rouge">dataset_store</code>).</p>

<p>However, we reference a <code class="language-plaintext highlighter-rouge">transaction</code> variable in both the input features and when defining what we predict, so what is this?</p>

<h3 id="feature-view">Feature View</h3>

<p>This is where our feature views come into play. Very similar to a dimension, as we define different information associated with some kind of entity.</p>

<p>This is what our <code class="language-plaintext highlighter-rouge">transaction</code> variable is from the previous example.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">feature_view</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"transactions"</span><span class="p">,</span>
    <span class="n">source</span><span class="o">=</span><span class="n">api_source</span><span class="p">,</span>
    <span class="n">materialized_source</span><span class="o">=</span><span class="n">FileSource</span><span class="p">.</span><span class="n">parquet_at</span><span class="p">(</span>
        <span class="s">"source_data/transactions.parquet"</span>
    <span class="p">),</span>
    <span class="n">description</span><span class="o">=</span><span class="s">"The bank transaction made by a user"</span><span class="p">,</span>
    <span class="n">contacts</span><span class="o">=</span><span class="p">[</span><span class="s">"MatsMoll"</span><span class="p">],</span>
    <span class="n">acceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">3</span><span class="p">),</span>
    <span class="n">unacceptable_freshness</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">7</span><span class="p">),</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Transaction</span><span class="p">:</span>
    <span class="n">transaction_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>

    <span class="n">user_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>
    <span class="n">account_id</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>
    
    <span class="n">accounting_date</span> <span class="o">=</span> <span class="n">Timestamp</span><span class="p">()</span>
    <span class="n">interest_date</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>

    <span class="n">amount</span> <span class="o">=</span> <span class="n">Float</span><span class="p">()</span>
    <span class="n">abs_amount</span> <span class="o">=</span> <span class="nb">abs</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span>

    <span class="n">text</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>
    <span class="n">transaction_type</span> <span class="o">=</span> <span class="n">String</span><span class="p">()</span>

    <span class="n">card_details</span> <span class="o">=</span> <span class="n">Json</span><span class="p">().</span><span class="n">description</span><span class="p">(</span>
        <span class="s">"Is only set where transaction_type = VISA VARE"</span>
    <span class="p">).</span><span class="n">is_optional</span><span class="p">()</span>

    <span class="n">is_expense</span> <span class="o">=</span> <span class="n">amount</span> <span class="o">&lt;</span> <span class="mi">0</span>
    <span class="n">is_income</span> <span class="o">=</span> <span class="n">amount</span> <span class="o">&gt;</span> <span class="mi">0</span>
    
    <span class="n">merchant_category</span> <span class="o">=</span> <span class="n">card_details</span><span class="p">.</span><span class="n">field</span><span class="p">(</span>
        <span class="s">"merchantCategoryDescription"</span><span class="p">,</span> <span class="n">String</span><span class="p">()</span>
    <span class="p">).</span><span class="n">is_optional</span><span class="p">()</span>
    
    <span class="n">text_embedding</span> <span class="o">=</span> <span class="n">text</span><span class="p">.</span><span class="n">embedding</span><span class="p">(</span>
        <span class="n">EmbeddingModel</span><span class="p">.</span><span class="n">huggingface</span><span class="p">(</span><span class="s">"all-MiniLM-L6-v2"</span><span class="p">)</span>
    <span class="p">)</span>

<span class="n">transaction</span> <span class="o">=</span> <span class="n">Transaction</span><span class="p">()</span>
</code></pre></div></div>

<p>There are a lot of things happening here, so let’s go through it.
First of all, we define all the information we expect a transaction to have, and its associated datatype.</p>

<p>Furthermore, we define where all this data will be fetched from <code class="language-plaintext highlighter-rouge">source</code>, but since this example will load from an API, we also write this data to a <code class="language-plaintext highlighter-rouge">materialized_source</code> working as a cache.</p>

<p>And similar to our <code class="language-plaintext highlighter-rouge">model_contract</code>, we define the expected freshness of this data. Making sure we are aware if our data is older than our expectations of 3 days.</p>

<p>Furthermore, by default, all columns will be required, which is why you see the <code class="language-plaintext highlighter-rouge">card_details</code> have a <code class="language-plaintext highlighter-rouge">.is_optional()</code>, as it can be missing in some scenarios.</p>

<p>Also, notice that we can add documentation directly on a feature with <code class="language-plaintext highlighter-rouge">.description(...)</code>, in case there are some extra contexts needed to justify the setup.</p>

<p>But not only that, we can even provide transformations in the feature views (<code class="language-plaintext highlighter-rouge">is_expense = amount &lt; 0</code>). And this without any strings. Therefore, providing both code completion, and linters can catch errors for you.</p>

<h3 id="data-lineage">Data Lineage</h3>

<p>All of this is nice to describe, but it gets better.
You see, since we have all of</p>

<p>these transformations and references, <code class="language-plaintext highlighter-rouge">aligned</code> will automatically collect data lineage through features, and even more interestingly, through models.</p>

<p>Meaning, if we had created a downstream model that depends on the <code class="language-plaintext highlighter-rouge">predicted_category</code>, <code class="language-plaintext highlighter-rouge">aligned</code> would know and show it to you.</p>

<p>Therefore, making it easier to provide a valid overview of your ML products.</p>
<video width="100%" controls="">
  <source src="/assets/videos/aligned-overall-data-lineage.mp4" type="video/mp4" />
View Data Lineage
</video>

<p>But it also makes it possible to prune unneeded transformations and debug transformations.</p>
<video width="100%" controls="">
  <source src="/assets/videos/aligned-test-transformations.mp4" type="video/mp4" />
Test data transformations
</video>

<p>Just look at how we can ask for the <code class="language-plaintext highlighter-rouge">question_embedding</code>, and it knows that you need to provide a <code class="language-plaintext highlighter-rouge">question</code> and a <code class="language-plaintext highlighter-rouge">description</code>.</p>

<h2 id="conclusion">Conclusion</h2>
<p>The AI landscape has evolved a lot in the past years, and we have a lot of good tooling to simplify the development of AI products. However, managing ML products on a system scale still needs a lot of implicit knowledge. Something that can also lead to extra work, longer onboarding times, and confusion.</p>

<p>As a result, I developed <code class="language-plaintext highlighter-rouge">aligned</code>, which tries to manage the data from AI products. Therefore, somewhat becoming the DBT of AI. As a result, <code class="language-plaintext highlighter-rouge">aligned</code> simplifies the development, management, and understanding of how our AI products actually behave.
I have only touched the surface of what <code class="language-plaintext highlighter-rouge">aligned</code> can do, but hopefully it shows some of the potential.</p>

<p>So if this is interesting, try out <a href="https://github.com/MatsMoll/aligned">aligned</a> and let me know what can be done to improve it further.</p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="Data Managment" /><category term="AI Products" /><summary type="html"><![CDATA[AI is evolving rapidly with new models every day. However, it is easy to get lost in the hype of new models and forget how AI will be integrated at a system-wide level.]]></summary></entry><entry><title type="html">Evaluating AI Models in Production</title><link href="https://matsmoll.github.io/posts/evaluating-ai-models-in-production" rel="alternate" type="text/html" title="Evaluating AI Models in Production" /><published>2023-09-24T00:00:00+00:00</published><updated>2023-09-24T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/evaluating-ml-models</id><content type="html" xml:base="https://matsmoll.github.io/posts/evaluating-ai-models-in-production"><![CDATA[<p><strong>Knowing the performance of an AI model is key to confidently delivering superior AI products. As such, it’s common practice for supervised learning models to set aside a specific dataset for evaluation during the training phase of our model.</strong></p>

<p><strong>While this evaluation process is quite valuable and usually helps determine if the model is good enough, it’s not without its drawbacks.</strong></p>

<p>The primary issue here is that the evaluation might not provide accurate performance metrics. Moreover, there’s a wide array of data errors that could come into play, for instance, leaking information either through features or over time. Consequently, if such data errors occur, they could quickly lead to inaccurate performance metrics, making the evaluation reports misleading or invalid. As a result, the most precise way to gauge a model’s performance is through monitoring it in production.</p>

<p>However, evaluating the model performance in production can often be down-prioritized or an afterthought. Potentially ending up in a situation where we never add evaluation in production.</p>

<p>Yet, the concept of evaluating models in production is, in essence, straightforward.</p>

<h2 id="what-is-needed">What is needed?</h2>
<p>There are two things needed to measure performance in production.</p>

<ol>
  <li>The predictions</li>
  <li>The ground truths</li>
</ol>

<p>We will already have the ground truth stored somewhere for most classification and regression models, as it is likely that we have trained a model ourselves using some version of a decision tree or regression model.</p>

<p>Furthermore, we hopefully have the predictions stored somewhere as we will need to serve them to the end user. However, the predictions and ground truths can be stored in different locations or data sources if using a microservice architecture, making it a bit more tricky.</p>

<p>One solution will be to ingest the predictions in a data warehouse or data lake. However, we must still set up data loaders and create processes that combine the data.</p>

<p>Another solution would be to use something like gantry.io that generates evaluation reports for you. However, this means you must send your data to a third-party service, which is not always possible or acceptable.</p>

<p>Therefore, I wanted to create a solution that enables easy evaluation of AI models in production using the business’s existing infrastructure.</p>

<h2 id="taxi-eta-model">Taxi ETA model</h2>
<p>Let’s look at an example of a Taxi ETA model and how we could evaluate performance in production.</p>

<p>Imagine we have a PostgreSQL table that contains a list of trips taken, where they started, where they ended, and the duration of the trip. Therefore, we can use the duration as the ground truth.</p>

<p>Furthermore, we store the predictions in another table containing the predicted duration, a timestamp of when it was predicted, and the model used. Therefore, we now have the location of our predictions as well.</p>

<p>But how should we model this?</p>

<p>I will use <code class="language-plaintext highlighter-rouge">aligned</code> for this, so let’s start modeling the ground truth.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">aligned</span> <span class="kn">import</span> <span class="n">PostgreSQLConfig</span><span class="p">,</span> <span class="n">UUID</span><span class="p">,</span> <span class="n">Int32</span>

<span class="n">db</span> <span class="o">=</span> <span class="n">PostgreSQLConfig</span><span class="p">(</span><span class="n">env_var</span><span class="o">=</span><span class="s">"PSQL_URL"</span><span class="p">)</span>

<span class="o">@</span><span class="n">feature_view</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"trip"</span><span class="p">,</span>
    <span class="n">batch_source</span><span class="o">=</span><span class="n">db</span><span class="p">.</span><span class="n">table</span><span class="p">(</span><span class="s">"trips"</span><span class="p">)</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Trip</span><span class="p">:</span>
    <span class="n">trip_id</span> <span class="o">=</span> <span class="n">UUID</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>

    <span class="n">duration</span> <span class="o">=</span> <span class="n">Int32</span><span class="p">()</span>
	
    <span class="n">number_of_passengers</span> <span class="o">=</span> <span class="n">Int32</span><span class="p">()</span>
</code></pre></div></div>

<p>The code above defines the schema we have stored in the Postgres database named <code class="language-plaintext highlighter-rouge">trips</code>. At the same time, we have specified that we will select the features using <code class="language-plaintext highlighter-rouge">trip_id</code> as the “identifier” or entity.</p>

<p>However, we have not yet defined our <code class="language-plaintext highlighter-rouge">duration</code> column as the ground truth. Mapping the ground truth is done in our model definition.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">aligned</span> <span class="kn">import</span> <span class="n">model_contract</span><span class="p">,</span> <span class="n">Float</span><span class="p">,</span> <span class="n">UUID</span><span class="p">,</span> <span class="n">EventTimestamp</span>
<span class="kn">from</span> <span class="nn">trips</span> <span class="kn">import</span> <span class="n">Trip</span><span class="p">,</span> <span class="n">db</span>

<span class="n">trip</span> <span class="o">=</span> <span class="n">Trip</span><span class="p">()</span>

<span class="o">@</span><span class="n">model_contract</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="s">"trip_duration"</span><span class="p">,</span>
    <span class="n">features</span><span class="o">=</span><span class="p">[</span><span class="n">trip</span><span class="p">.</span><span class="n">number_of_passengers</span><span class="p">,</span> <span class="p">...],</span>
    <span class="n">predictions_source</span><span class="o">=</span><span class="n">db</span><span class="p">.</span><span class="n">table</span><span class="p">(</span><span class="s">"trip_predictions"</span><span class="p">)</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">PredictedTrips</span><span class="p">:</span>
    <span class="n">trip_id</span> <span class="o">=</span> <span class="n">UUID</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>
	
    <span class="n">predicted_duration</span> <span class="o">=</span> <span class="n">trip</span><span class="p">.</span><span class="n">duration</span><span class="p">.</span><span class="n">as_regression_label</span><span class="p">()</span>
</code></pre></div></div>

<p>Again, the above code defines a model contract where we list the features that we use as input to the model, the table used to store the predictions in, that we will query the predictions using the <code class="language-plaintext highlighter-rouge">trip_id</code>, and that the <code class="language-plaintext highlighter-rouge">trip.duration</code> column is the label that we predict.</p>

<h2 id="evaluate-the-model">Evaluate the model</h2>
<p>The above code has everything that we need. We have defined where we store our ground truths, predictions and that the <code class="language-plaintext highlighter-rouge">duration</code>  column is the target for our regression model. However, now we need to evaluate the predictions.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">sklearn.metrics</span> <span class="kn">import</span> <span class="n">mean_squared_error</span>
<span class="kn">from</span> <span class="nn">aligned</span> <span class="kn">import</span> <span class="n">FeatureStore</span><span class="p">,</span> <span class="n">PostgreSQLConfig</span>

<span class="n">store</span> <span class="o">=</span> <span class="n">FeatureStore</span><span class="p">.</span><span class="n">from_dir</span><span class="p">(</span><span class="s">"."</span><span class="p">)</span>

<span class="n">db</span> <span class="o">=</span> <span class="n">PostgreSQLConfig</span><span class="p">(</span><span class="s">"PSQL_URL"</span><span class="p">)</span>

<span class="n">entities</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="n">fetch</span><span class="p">(</span><span class="s">"""
    SELECT trip_id
    FROM finished_trips_view
"""</span><span class="p">)</span>

<span class="nb">eval</span> <span class="o">=</span> <span class="k">await</span> <span class="n">store</span><span class="p">.</span><span class="n">model</span><span class="p">(</span><span class="s">"trip_duration"</span><span class="p">)</span>\
    <span class="p">.</span><span class="n">with_labels</span><span class="p">()</span>\
    <span class="p">.</span><span class="n">predictions_for</span><span class="p">(</span><span class="n">entities</span><span class="p">)</span>\
    <span class="p">.</span><span class="n">to_pandas</span><span class="p">()</span>

<span class="n">mse</span> <span class="o">=</span> <span class="n">mean_square_error</span><span class="p">(</span><span class="nb">eval</span><span class="p">.</span><span class="n">predictions</span><span class="p">,</span> <span class="nb">eval</span><span class="p">.</span><span class="n">ground_truths</span><span class="p">)</span>
</code></pre></div></div>

<p>In the above example, will we select the instances where a ground truth exists, join the predictions for them, and the ground truths themselves.</p>

<p>Our response from the database contains all of the data in one data frame but nicely separates the different information for us. They make it easy to compute metrics using the attributes of <code class="language-plaintext highlighter-rouge">ground_truths</code> and <code class="language-plaintext highlighter-rouge">predictions</code>.</p>

<h2 id="conclusion">Conclusion</h2>
<p>Evaluation is an essential part of AI applications. However, evaluating how well our models perform in production can often be down-prioritised, even though this is the most accurate metric for our model, as data leakage is way less likely in such an environment.</p>

<p>Furthermore, using the <code class="language-plaintext highlighter-rouge">aligned</code> package makes it easy to define the relationship between our ground truths and predictions. While also defining where we to fetch the different information. Such a setup makes it easier to load the data needed to evaluate our models living in production.</p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="AI" /><category term="ML" /><category term="Evaluation" /><summary type="html"><![CDATA[Knowing the performance of an AI model is key to confidently delivering superior AI products. As such, it’s common practice for supervised learning models to set aside a specific dataset for evaluation during the training phase of our model.]]></summary></entry><entry><title type="html">DX can be more than just ‘It Feels Good’</title><link href="https://matsmoll.github.io/posts/dx-is-more-than-it-feels-good" rel="alternate" type="text/html" title="DX can be more than just ‘It Feels Good’" /><published>2023-08-30T00:00:00+00:00</published><updated>2023-08-30T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/dx-is-more-than-it-feels-good</id><content type="html" xml:base="https://matsmoll.github.io/posts/dx-is-more-than-it-feels-good"><![CDATA[<p>Many developers often lean towards tools that “feel good” and argue they have good Developer Experience (DX). However, this subjective measure of “feel good” is rooted in past experiences and familiarity with similar tools, making the argument of “good DX” worthless. Which is also something ThePrimeagen have argued for. Furthermore, relying solely on what feels comfortable can hinder innovation and limit our perspective. Instead, a more objective way of measuring DX and evaluating code quality will be needed if we continue using the term DX.</p>

<p>Therefore, using more tried-and-tested principles can benefit this debate. However, I am not proposing to use principles like <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a>, <a href="https://en.wikipedia.org/wiki/Don%27t_repeat_yourself">DRY</a>, or <a href="https://books.google.no/books/about/Clean_Code.html?id=hjEFCAAAQBAJ&amp;source=kp_book_description&amp;redir_esc=y">Clean Code</a>; I’m instead proposing to use existing UX principles and apply them across different programming languages, frameworks, and paradigms.</p>

<p>But UX is for UI design, I hear you say.</p>

<p>And I would answer - no, it is not; it is much more flexible, so let me show you.</p>

<h2 id="example-1-pydantic-vs-dataclasses">Example 1: Pydantic vs dataclasses</h2>
<p>Let’s evaluate two codebases that fulfill the same need but use different implementations. Furthermore, we will determine why one solution is better using UX principles rather than the unclear “feel better.”</p>

<p>The following code makes it possible to encode and load a JSON object. Our first implementation will use <code class="language-plaintext highlighter-rouge">dataclasses</code>, and the second will use <code class="language-plaintext highlighter-rouge">pydantic</code>.
Now, <code class="language-plaintext highlighter-rouge">dataclasses</code>’s use-case is not to make JSON easier to work with. However, <code class="language-plaintext highlighter-rouge">dataclasses</code> can implement our “happy path,” so it could have been used for such a use-case.</p>

<p>So, let’s look at the <code class="language-plaintext highlighter-rouge">dataclasses</code> implementation.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">data</span> <span class="n">class</span><span class="p">,</span> <span class="n">asdict</span>
<span class="kn">import</span> <span class="nn">json</span>

<span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">Size</span><span class="p">:</span>
    <span class="n">width</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">height</span><span class="p">:</span> <span class="nb">int</span>
    
<span class="n">size</span> <span class="o">=</span> <span class="n">Size</span><span class="p">(</span><span class="n">width</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span> <span class="n">height</span><span class="o">=</span><span class="mi">20</span><span class="p">)</span>
<span class="n">size_json</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">asdict</span><span class="p">(</span><span class="n">size</span><span class="p">))</span>
<span class="n">reconstructed</span> <span class="o">=</span> <span class="n">Size</span><span class="p">(</span><span class="o">**</span><span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">size_json</span><span class="p">))</span>
</code></pre></div></div>

<p>This is pretty straightforward. Declare a data class and its properties, create an instance of the object, convert it to a dict, and then to a JSON string. Lastly, decode the JSON string back to a dictionary and pass the fields to the constructor.</p>

<p>So now is the <code class="language-plaintext highlighter-rouge">pydantic</code> solution.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">pydantic</span> <span class="kn">import</span> <span class="n">BaseModel</span>
<span class="kn">import</span> <span class="nn">json</span>

<span class="k">class</span> <span class="nc">Size</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">width</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">height</span><span class="p">:</span> <span class="nb">int</span>

<span class="n">size</span> <span class="o">=</span> <span class="n">Size</span><span class="p">(</span><span class="n">width</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span> <span class="n">height</span><span class="o">=</span><span class="mi">20</span><span class="p">)</span>
<span class="n">size_json</span> <span class="o">=</span> <span class="n">size</span><span class="p">.</span><span class="n">model_dump_json</span><span class="p">()</span>
<span class="n">reconstructed</span> <span class="o">=</span> <span class="n">Size</span><span class="p">(</span><span class="o">**</span><span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">size_json</span><span class="p">))</span>
</code></pre></div></div>

<p>So, both solutions are very similar, and if I had gone with my “feeling,” I would have implemented the <code class="language-plaintext highlighter-rouge">dataclasses</code> solution.</p>

<p>Why? Because I do not like that we need to subclass from <code class="language-plaintext highlighter-rouge">BaseModel</code> in <code class="language-plaintext highlighter-rouge">pydantic</code>. Furthermore, <code class="language-plaintext highlighter-rouge">pydantic</code> is an external dependency, while <code class="language-plaintext highlighter-rouge">dataclasses</code> is already included in Python.</p>

<p>However, would this be the correct choice, and if so, why?</p>

<h3 id="evaluate-using-ux">Evaluate using UX</h3>
<p>To evaluate our use case, I will mainly use <a href="https://www.nngroup.com/articles/ten-usability-heuristics/">Nilsens Heuristics</a>. These are very generic, but it is also what makes them so flexible. Furthermore, it is still better than “it feels good,” so I think it is a step in the right direction.</p>

<p>However, using other UX principles like the <a href="https://www.nngroup.com/articles/gestalt-proximity/">Proximity principle</a> - related information should be close to each other, which could be used to argue against C header files. Other concepts, e.g., from “<a href="https://www.amazon.com/Design-Everyday-Things-Donald-Norman/dp/1452654123">the design of everyday things</a>” would also work.</p>

<h3 id="error-prevention">Error prevention</h3>
<blockquote>
  <p>Good error messages are important, but the best designs carefully prevent problems from occurring in the first place.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>While the <code class="language-plaintext highlighter-rouge">dataclasses</code> and <code class="language-plaintext highlighter-rouge">pydantic</code> solutions appear similar, their approaches to error handling are notably different.</p>

<p>Our <code class="language-plaintext highlighter-rouge">dataclasses</code> solution allows the following invalid input.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">size</span> <span class="o">=</span> <span class="n">Size</span><span class="p">(</span><span class="n">width</span><span class="o">=</span><span class="s">"10m"</span><span class="p">,</span> <span class="n">height</span><span class="o">=</span><span class="s">"20cm"</span><span class="p">)</span>
</code></pre></div></div>
<p>Here, we send in a string while we expect an integer to be passed, and we can use this and send it into functions using the <code class="language-plaintext highlighter-rouge">Size</code> object.
However, <code class="language-plaintext highlighter-rouge">pydantic</code> will validate and catch such a case. Therefore, throwing an error if we do not match the expected data pattern prevents downstream problems.</p>

<p>Other examples that improve DX by preventing errors could be using something like Protocol Buffers rather than JSON for a similar reason.</p>

<h3 id="visibility-of-system-status">Visibility of system status</h3>
<blockquote>
  <p>The design should always keep users informed about what is going on through appropriate feedback within a reasonable amount of time.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>It can be harder to provide feedback in code as it is a static artifact. However, we can leverage tools like type systems, compilers, and linters, to fulfill this.</p>

<p>Furthermore, to continue our above example, let’s add a new method.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">area</span><span class="p">(</span><span class="n">size</span><span class="p">:</span> <span class="n">Size</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">size</span><span class="p">.</span><span class="n">width</span> <span class="o">*</span> <span class="n">size</span><span class="p">.</span><span class="n">height</span>
</code></pre></div></div>

<p>Since <code class="language-plaintext highlighter-rouge">pydantic</code> throws when the data pattern is invalid, will <code class="language-plaintext highlighter-rouge">pydantic</code>, as a result, ensure our system state.
Therefore, only <code class="language-plaintext highlighter-rouge">pydantic</code> will make our system state more visible, as we know that our <code class="language-plaintext highlighter-rouge">Size</code> object contains the expected state that we want.</p>

<h3 id="help-users-recognize-diagnose-and-recover-from-errors">Help users recognize, diagnose, and recover from errors</h3>
<blockquote>
  <p>Error messages should be expressed in plain language (no error codes), precisely indicate the problem, and constructively suggest a solution.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>This is where the original UX heuristic may differ from “my” DX one. We sometimes need the technical jargon and details to fix the problem. However, the heuristic is still valuable, as precise error messages with guidance are super valuable.</p>

<p>Again, our <code class="language-plaintext highlighter-rouge">dataclasses</code> solution can fail if we call <code class="language-plaintext highlighter-rouge">area(size),</code> which can lead to the following error message:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TypeError: can't multiply sequence by non-int of type "str"
</code></pre></div></div>
<p>This tells us precisely what the problem is. However, we are told we can’t do what we want, which is multiplying. Furthermore, the compounding effect of the mismatching system state makes it harder to understand why we can’t multiply. The reason for this error is because of invalid input.</p>

<p>However, our <code class="language-plaintext highlighter-rouge">pydantic</code> solution provides an error when trying to create an object. But this leads to the following error.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ValidationError: 2 validation errors for Size
width
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='10 m', input_type=str]
    For further information visit ...
height
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='20 cm', input_type=str]
    For further information visit ...
</code></pre></div></div>

<p>This message mentions where the error is located, what is wrong, how to fix it, and a link for more information.
It is more apparent that we have inputted invalid data, and most likely not due to poor code.</p>

<p>This guidance also makes Rust’s compiler helpful, as it helps you locate and guide you in the correct solution when something is wrong.</p>

<p>With the presented UX principles in mind, my decision to go with what felt best would have been a poor decision, potentially leading to more frustration in the long run.</p>

<p>However, this is one case; what about something that differs more?</p>

<h2 id="example-2-aligned-vs-pandas">Example 2: Aligned vs Pandas</h2>
<p>Let’s look at another solution with data processing using an unconventional approach while presenting new heuristics.</p>

<p>So, let’s consider a scenario where we load geo data from a trip database, compute some features, and then use an ML model to predict the duration of the trip. We will use <code class="language-plaintext highlighter-rouge">pandas</code> and <code class="language-plaintext highlighter-rouge">aligned</code> to do the same thing. So, let’s look at the <code class="language-plaintext highlighter-rouge">pandas</code> code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">con</span> <span class="o">=</span> <span class="n">create_engine</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"TAXI_DB_URL"</span><span class="p">])</span>
<span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">read_sql_query</span><span class="p">(</span>
    <span class="n">sql</span><span class="o">=</span><span class="s">"""
SELECT 
    id as trip_id, 
    
    pickup_latitude, 
    pickup_longitude,
    
    dropoff_latitude,  
    dropoff_longitude, 
    
    picked_up_at 
FROM trips;
"""</span><span class="p">,</span> 
    <span class="n">con</span><span class="o">=</span><span class="n">con</span>
<span class="p">)</span>

<span class="n">df</span><span class="p">[</span><span class="s">"lat_diff"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"pickup_latitude"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"dropoff_latitude"</span><span class="p">])</span> <span class="o">**</span> <span class="mi">2</span>
<span class="n">df</span><span class="p">[</span><span class="s">"long_diff"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"pickup_longitude"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"dropoff_longitude"</span><span class="p">])</span> <span class="o">**</span> <span class="mi">2</span>
<span class="n">df</span><span class="p">[</span><span class="s">"travel_distance"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"lat_diff"</span><span class="p">]</span> <span class="o">+</span> <span class="n">df</span><span class="p">[</span><span class="s">"long_diff"</span><span class="p">])</span> <span class="o">**</span> <span class="mf">0.5</span>

<span class="c1"># The day in the month
</span><span class="n">df</span><span class="p">[</span><span class="s">"day_of_week"</span><span class="p">]</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">"picked_up_at"</span><span class="p">].</span><span class="n">dt</span><span class="p">.</span><span class="n">day</span>

<span class="nb">input</span> <span class="o">=</span> <span class="n">df</span><span class="p">[[</span><span class="s">"day_of_week"</span><span class="p">,</span> <span class="s">"travel_distance"</span><span class="p">]]</span>
<span class="n">duration</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="nb">input</span><span class="p">)</span>
</code></pre></div></div>

<p>This is a common codebase for data applications. However, this solution has similar problems to our <code class="language-plaintext highlighter-rouge">dataclasses</code> solution earlier. 
We cannot validate our SQL code because it is written in a pure string. We do not make our code check against bad data, as we do not have data validation. This is intentionally not added, as presenting too much code in a blog post is uninteresting. Furthermore, experimental code can quickly skip data validation, as extra dependencies are needed, leading to odd errors.
Therefore, making our <code class="language-plaintext highlighter-rouge">df["picked_up_at"].dt.day</code> potentially error-prone, as we forcefully cast our value to a date time type without converting the <code class="language-plaintext highlighter-rouge">picked_up_at</code> to a proper value.</p>

<p>Furthermore, we can not use our Python <a href="https://en.wikipedia.org/wiki/Language_Server_Protocol">Language Server Protocol (LSP)</a> to catch naming errors in our data frame, as we use strings to reference values. Leading to the potential error <code class="language-plaintext highlighter-rouge">KeyError: 'day_of_weak'</code> on runtime rather than an error on compile time. Similar to how we can not type check for values in dictionaries, but we can if described as a <code class="language-plaintext highlighter-rouge">class</code> object.</p>

<p>Therefore, we have little to no system status visibility, leading to less error prevention, and we do not help our users recognize, diagnose, and recover from errors.</p>

<p>A solution like <code class="language-plaintext highlighter-rouge">aligned</code> can significantly enhance the UX heuristics we’ve discussed. A quick introduction to <code class="language-plaintext highlighter-rouge">aligned.</code> Aligned is an ML tool used to make data more consistent at a system level. Therefore making it possible to standardize transformations in a technology-agnostic format. Making it possible to do ETL, even though it is not its primary purpose.</p>

<p>So, how do <code class="language-plaintext highlighter-rouge">aligned</code> compare?</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">taxi_db</span> <span class="o">=</span> <span class="n">PostgreSQLConfig</span><span class="p">(</span><span class="n">env_var</span><span class="o">=</span><span class="s">"TAXI_DB_URL"</span><span class="p">)</span>

<span class="o">@</span><span class="n">feature_view</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span> <span class="s">"trips,"</span>
    <span class="n">description</span><span class="o">=</span> <span class="s">"Features related to the departure of a taxi ride,"</span>
    <span class="n">batch_source</span><span class="o">=</span><span class="n">taxi_db</span><span class="p">.</span><span class="n">table</span><span class="p">(</span><span class="s">"trips"</span><span class="p">,</span> <span class="n">mapping_keys</span><span class="o">=</span><span class="p">{</span><span class="s">"id"</span><span class="p">:</span> <span class="s">"trip_id"</span><span class="p">})</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Trips</span><span class="p">:</span>
    <span class="n">trip_id</span> <span class="o">=</span> <span class="n">UUID</span><span class="p">().</span><span class="n">as_entity</span><span class="p">()</span>

    <span class="n">picked_up_at</span> <span class="o">=</span> <span class="n">EventTimestamp</span><span class="p">()</span>

    <span class="n">dropoff_latitude</span> <span class="o">=</span> <span class="n">Float</span><span class="p">().</span><span class="n">is_required</span><span class="p">()</span>
    <span class="n">dropoff_longitude</span> <span class="o">=</span> <span class="n">Float</span><span class="p">().</span><span class="n">is_required</span><span class="p">()</span>

    <span class="n">pickup_latitude</span> <span class="o">=</span> <span class="n">Float</span><span class="p">().</span><span class="n">is_required</span><span class="p">()</span>
    <span class="n">pickup_longitude</span> <span class="o">=</span> <span class="n">Float</span><span class="p">().</span><span class="n">is_required</span><span class="p">()</span>
    
    <span class="n">lat_diff</span> <span class="o">=</span> <span class="p">(</span><span class="n">pickup_latitude</span> <span class="o">-</span> <span class="n">dropoff_latitude</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span>
    <span class="n">long_diff</span> <span class="o">=</span> <span class="p">(</span><span class="n">pickup_longitude</span> <span class="o">-</span> <span class="n">dropoff_longitude</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span>
    <span class="n">travel_distance</span> <span class="o">=</span> <span class="p">(</span><span class="n">lat_diff</span> <span class="o">+</span> <span class="n">long_diff</span><span class="p">)</span> <span class="o">**</span> <span class="mf">0.5</span>
        
    <span class="n">day_of_week</span> <span class="o">=</span> <span class="n">picked_up_at</span><span class="p">.</span><span class="n">day</span><span class="p">.</span><span class="n">description</span><span class="p">(</span><span class="s">"The day in the month"</span><span class="p">)</span>
    
    
<span class="nb">input</span> <span class="o">=</span> <span class="k">await</span> <span class="p">(</span><span class="n">Trips</span><span class="p">.</span><span class="n">query</span><span class="p">()</span>
    <span class="p">.</span><span class="n">select</span><span class="p">([</span><span class="s">"travel_distance"</span><span class="p">,</span> <span class="s">"day_of_week"</span><span class="p">])</span>
    <span class="p">.</span><span class="nb">all</span><span class="p">()</span>
    <span class="p">.</span><span class="n">to_pandas</span><span class="p">()</span>
<span class="p">)</span>
<span class="n">preds</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="nb">input</span><span class="p">)</span>
</code></pre></div></div>

<p>Quickly, <code class="language-plaintext highlighter-rouge">aligned</code> makes it possible to declare data schemas and transformations using a class, enabling us to leverage the LSP and find errors earlier. Making it more straightforward which state our data is expected to be in and reducing errors by validating the anticipated state. Very similar to <code class="language-plaintext highlighter-rouge">pydantic</code>.</p>

<p>But let’s compare using some new UX principles.</p>

<h3 id="aesthetic-and-minimalist-design">Aesthetic and minimalist design</h3>
<blockquote>
  <p>Interfaces should not contain information that is irrelevant or rarely needed. Every extra unit of information in an interface competes with the relevant units of information and diminishes their relative visibility.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>The <code class="language-plaintext highlighter-rouge">pandas</code> solution contains fewer characters, which makes it more minimal in that sense, as we type less. However, the transformations in our <code class="language-plaintext highlighter-rouge">pandas</code> solution keep repeating <code class="language-plaintext highlighter-rouge">df["..."]</code>, which is irrelevant information. Therefore cluttering our logic and creating more noise. However, this is where <code class="language-plaintext highlighter-rouge">aligned</code> makes it way less noisy. Just look at how the two different lines differ below.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Pandas
</span><span class="n">df</span><span class="p">[</span><span class="s">"lat_diff"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s">"pickup_latitude"</span><span class="p">]</span> <span class="o">-</span> <span class="n">df</span><span class="p">[</span><span class="s">"dropoff_latitude"</span><span class="p">])</span> <span class="o">**</span> <span class="mi">2</span>

<span class="c1"># Aligned
</span><span class="n">lat_diff</span> <span class="o">=</span> <span class="p">(</span><span class="n">pickup_latitude</span> <span class="o">-</span> <span class="n">dropoff_latitude</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span>
</code></pre></div></div>

<p>Both do the same thing, but <code class="language-plaintext highlighter-rouge">aligned</code> makes it possible to remove all the <code class="language-plaintext highlighter-rouge">df</code> references while also removing the usage of <code class="language-plaintext highlighter-rouge">dict</code>-like data access. Therefore, our business logic is described with less noise <code class="language-plaintext highlighter-rouge">aligned</code>, and I would take less noise over fewer characters.</p>

<h3 id="recognition-rather-than-recall">Recognition rather than recall</h3>
<blockquote>
  <p>Minimize the user’s memory load by making elements, actions, and options visible. The user should not have to remember information from one part of the interface to another.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>Furthermore, our <code class="language-plaintext highlighter-rouge">pandas</code> solution relies heavily on strings or a <code class="language-plaintext highlighter-rouge">dict</code> like access method. This has the unfortunate effect of hiding valuable information for our LSP. This results in less accurate code completions, leading to more cognitive load on the user, as a result, and as mentioned earlier. Our <code class="language-plaintext highlighter-rouge">pandas</code> code needs the user to remember that the <code class="language-plaintext highlighter-rouge">picked_up_at</code> is a date time and cast it to such a type <code class="language-plaintext highlighter-rouge">df["picked_up_at"].dt.day</code>.</p>

<p>However, <code class="language-plaintext highlighter-rouge">aligned</code> uses variables combined with defined types without casting. Therefore, helping our LSP provide valuable information about the types that each method returns and the potential paths our program can take. Enabling our users to search through our auto-completion and recognize possible programming paths. Therefore, recalling less than our <code class="language-plaintext highlighter-rouge">pandas</code> solution.</p>

<p>Again, this is why using the proximity principle can also help with recognizing more than recalling it.</p>

<h3 id="consistency-and-standards">Consistency and standards</h3>
<blockquote>
  <p>Users should not have to wonder whether different words, situations, or actions mean the same thing.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>Following conventions and common standards is always good, making it easier to transition from different domains. One such standard could be the usage of SQL, using the standard operators as <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">-</code>, <code class="language-plaintext highlighter-rouge">*</code> etc., or using common names on data type as <code class="language-plaintext highlighter-rouge">Float,</code> <code class="language-plaintext highlighter-rouge">Int64</code>, and maybe not ‘long long int.`</p>

<p>However, this is where <code class="language-plaintext highlighter-rouge">pandas</code> and <code class="language-plaintext highlighter-rouge">aligned</code> provide consistency and standards at different abstraction levels. As <code class="language-plaintext highlighter-rouge">pandas</code> provides data transformation consistency across different machine architectures, while <code class="language-plaintext highlighter-rouge">aligned</code> provides consistency at an application service level, transformations, and data dependencies can be shared in a serialized format.</p>

<p>Therefore, they both provide consistency, depending on the intended use case. As a result, DX is not as easy as “this is better,” but it is more a question about “it is better for this use-case.”</p>

<p>For, If we want to run one script that does data analytics occasionally, then <code class="language-plaintext highlighter-rouge">pandas</code> provides the consistency we want, as we can be confident the transformations will run on multiple types of machine hardware. However, if we need data transformations to be shared across employees or applications, then <code class="language-plaintext highlighter-rouge">pandas</code> may not provide good enough consistency, but <code class="language-plaintext highlighter-rouge">aligned</code> will.</p>

<p>Furthermore, this is where we can argue for DRY, as the whole point is to make our codebase more consistent when changes get implemented. However, will repeating the same code in two places be bad for consistency? Probably not, so maybe following the “Rule of three” rather than a pure DRY approach would be better.</p>

<h3 id="flexibility-and-efficiency-of-use">Flexibility and efficiency of use</h3>
<blockquote>
  <p>Shortcuts — hidden from novice users — may speed up the interaction for the expert user so that the design can cater to both inexperienced and experienced users.</p>

  <p><a href="https://www.nngroup.com/articles/ten-usability-heuristics/">NN Group</a></p>
</blockquote>

<p>Furthermore, both solutions enable our users to speed up their work for common use cases. 
The <code class="language-plaintext highlighter-rouge">read_sql_query</code> makes connecting to an SQL db easier in <code class="language-plaintext highlighter-rouge">pandas</code>, rather than reading them manually and converting them into the desired format. However, <code class="language-plaintext highlighter-rouge">pandas</code> is intended to be used as a generic data processing framework and not for ML use cases.</p>

<p>Therefore, fulfilling needs specific to ML can be clunky to do with <code class="language-plaintext highlighter-rouge">pandas</code>, such as creating train, test, validation data sets, or setting upstream processing for low latency features.</p>

<p>As a result, <code class="language-plaintext highlighter-rouge">aligned</code> can streamline such use cases and make them easier, enabling one to opt into more flexible solutions when needed. That’s why you see the <code class="language-plaintext highlighter-rouge">.to_pandas()</code> while also allowing you to set up a stream processing worker with the following code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">aligned.worker</span> <span class="kn">import</span> <span class="n">StreamWorker</span>
<span class="kn">from</span> <span class="nn">aligned</span> <span class="kn">import</span> <span class="n">RedisConfig</span><span class="p">,</span> <span class="n">FileSource</span>

<span class="n">definitions</span> <span class="o">=</span> <span class="n">FileSource</span><span class="p">.</span><span class="n">json_at</span><span class="p">(</span><span class="s">"data-defintions/v1.json"</span><span class="p">)</span>

<span class="n">worker</span> <span class="o">=</span> <span class="n">StreamWorker</span><span class="p">.</span><span class="n">from_reference</span><span class="p">(</span>
    <span class="n">definitions</span><span class="p">,</span>
    <span class="n">sink_source</span><span class="o">=</span><span class="n">RedisConfig</span><span class="p">.</span><span class="n">localhost</span><span class="p">(),</span>
<span class="p">).</span><span class="n">expose_metrics_at</span><span class="p">(</span><span class="n">port</span><span class="o">=</span><span class="mi">8000</span><span class="p">)</span>
</code></pre></div></div>
<p>Therefore, finding tools specializing in your needs will make you move faster. However, making it possible to opt into the underlying details will be helpful to make our tools flexible enough.</p>

<p>Furthermore, this is why I like the <a href="https://docs.vapor.codes/fluent/advanced/">fluent ORM</a> created by the <a href="https://docs.vapor.codes">Vapor team</a>. As they provide:
A low-level raw SQL client.
A SQL query builder based on existing data classes.
A high-level ORM that fulfills common use cases.
It provides shortcuts while also enabling flexibility when needed.</p>

<h3 id="latency">Latency</h3>
<p>Lastly, even though this is not a heuristic, I would like to add performance as a criterion for DX, or how I would instead frame it, latency.</p>

<p>Furthermore, improved performance can affect cost. However, cost is more of a business side-effect, and I will not consider it affecting DX. I am only interested in how performance affects latency, as poor latency will degrade the developer experience, while good performance will be barely noticeable.</p>

<p>This happened to me at work, where we used <code class="language-plaintext highlighter-rouge">pandas</code> for most of our ETL. However, at some point, <code class="language-plaintext highlighter-rouge">pandas</code> lead to way too long run times. Therefore, switching to <code class="language-plaintext highlighter-rouge">polar</code> reduced the run time to 1 / 10, making the latency acceptable while avoiding complex <code class="language-plaintext highlighter-rouge">spark</code> clusters.</p>

<p>This is why <code class="language-plaintext highlighter-rouge">aligned</code> makes it possible to change the processing engine when needed. Therefore avoiding premature optimization, reducing costs, and making the solution performant.</p>

<p>In other words, high latency can ruin the developer experience, but only some will notice a more performant solution than an already good enough solution.</p>

<h3 id="delving-deeper-into-heuristics">Delving Deeper into Heuristics</h3>
<p>While I’ve touched on several heuristics in this piece, there’s a wealth more out there—like “Help and documentation”, “User control and freedom”, and “Match between system and the real world”. I’d love to dive deeper, but we’re already wading through quite a bit here. Perhaps in a future post!</p>

<h2 id="conclusion">Conclusion</h2>
<p>Developer Experience (DX) has been somewhat diluted to represent tools developers find comfortable. Therefore, it often means “what I am used to.” But to drive valuable discussions and innovation, there’s a pressing need to view DX through a more objective and comprehensive lens. Incorporating UX principles to evaluate code can pave the way for a more inclusive and critical approach, helping developers describe their code in new ways and make more educated choices for why technologies are good or bad. Even when they look foreign and odd at first glance.</p>

<p>If you found the read interesting, please share or give <a href="https://github.com/MatsMoll/aligned">Aligned</a> a star. Thanks for reading.</p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="DX" /><category term="UX" /><category term="Software Design" /><summary type="html"><![CDATA[Many developers often lean towards tools that “feel good” and argue they have good Developer Experience (DX). However, this subjective measure of “feel good” is rooted in past experiences and familiarity with similar tools, making the argument of “good DX” worthless. Which is also something ThePrimeagen have argued for. Furthermore, relying solely on what feels comfortable can hinder innovation and limit our perspective. Instead, a more objective way of measuring DX and evaluating code quality will be needed if we continue using the term DX.]]></summary></entry><entry><title type="html">Understanding the chaotic landscape of MLOps</title><link href="https://matsmoll.github.io/posts/understanding-the-chaotic-landscape-of-mlops" rel="alternate" type="text/html" title="Understanding the chaotic landscape of MLOps" /><published>2023-03-01T00:00:00+00:00</published><updated>2023-03-01T00:00:00+00:00</updated><id>https://matsmoll.github.io/posts/understand-the-chaotic-landscape-of-mlops</id><content type="html" xml:base="https://matsmoll.github.io/posts/understanding-the-chaotic-landscape-of-mlops"><![CDATA[<p>Navigating the landscape of MLOps can be an overwhelming task. There are thousands of tools to explore, and understanding which problem each tool tackles can take time to grasp.</p>

<p>However, the core concept of an ML model is simple. For some input, produce an output. Or, as math would describe it, \(f(X) \rightarrow y\). So how could this simple function lead to the chaotic landscape that exists?</p>

<p>We will look at some common components for AI models deployed behind an API to keep this post reasonably sized.</p>

<h2 id="categories-of-ml-products">Categories of ML products</h2>
<p>It helped to categorize our ML products into three high-level groups. These categories make understanding the core problem statement and the solutions’ constraints easier.</p>

<h3 id="the-model---fx">The model - \(f(X)\)</h3>
<p>Let’s start with the most popular category - the model.
Solving a problem from this category can be seen as a model-centric approach. Therefore, making it one of the most popular approaches as well. As we need a model to have an ML product.</p>

<h4 id="model-creation">Model creation</h4>
<p>Before we can do anything, we need a model to use. Thankfully, tools such as <a href="https://scikit-learn.org/stable/index.html">sklearn</a>, <a href="https://www.tensorflow.org">TensorFlow</a>, <a href="https://pytorch.org">PyTorch</a>, etc., have made it possible to train a model with a few lines of code - <code class="language-plaintext highlighter-rouge">model.fit(X, y)</code>.</p>

<p>This problem statement is what we see when being introduced to ML for the first time, and rightfully so.</p>

<p>However, this is only the start of even more questions. One natural question would be - How can we release a model, making it accessible to others?</p>

<h4 id="inference-server">Inference server</h4>
<p>Meet one approach, an inference server. This approach often requires a separate server that handles model inference. Therefore, making it possible to access additional hardware like GPUs or TPUs if needed. As a result, an inference server is a standard solution.</p>

<p>However, typing up a new codebase only to make one model accessible can sound like a lot of work. Since an inference server is a common requirement, many solid solutions have popped up. For example, <a href="https://huggingface.co/inference-endpoints">Huggingface</a> offers a way to deploy transformer models within a few minutes, while <a href="https://openai.com">OpenAI</a> offers pre-trained models through an API. Then you have solutions like <a href="https://kserve.github.io/website/0.10/">KServe</a>, which makes it reasonably easy to deploy custom AI models on a <a href="https://kubernetes.io">Kubernetes</a> cluster</p>

<p>But with our model deployed comes a new problem - What if I want to update my model to a new version?</p>

<h4 id="model-registry">Model Registry</h4>
<p>Here is where the model registry comes in handy. In the same way we version code, will there be a need to version models at some point. Either because we found a new feature that could help or because the old pattern does not apply anymore - known as concept drift.</p>

<p>Therefore, we need to have some way of managing \(f_0(X), f_1(X), ..., f_n(X)\), and so on.</p>

<p>One simple way could be to store our models in a storage service like <a href="https://aws.amazon.com">AWS S3</a>. Such a solution can work if we have a few artifacts to handle. 
However, the complexity can quickly increase as we want the model to do more. For example, we could add pre-processing and post-processing, or we want to manage the runtime environment of the model. 
Using a more specialized model registry component for such a requirement would make sense. One such tool would be <a href="https://www.mlflow.org">MLFlow</a>.</p>

<p>But again, as we start to train new models regularly, new problems arise. For example, how will we know the differences between all our models?</p>

<h4 id="model-card">Model Card</h4>
<p>Here is where model cards come in. It summarizes what the model will do, how it was created, its limitations, some performance statistics, and more. Therefore, a model card summarizes \(f_n(X)\).</p>

<p>One tool that collects this information is <a href="https://github.com/tensorflow/model-card-toolkit">model_card_toolkit</a>. However, we will need to dive deeper into our model. So could we see how our model came to be?</p>

<h4 id="experiment-tracking">Experiment tracking</h4>
<p>Experiment tracking is one solution to such a problem. Providing a log of metrics as our model is trained, in addition, can all kinds of evaluation graphs be logged - as confusion matrices, and log which features contribute the most to our output. Therefore, answering how we arrived at \(f_n(X)\).</p>

<p>Some tools that answer this question are <a href="https://wandb.ai">Weights &amp; Biases</a>, <a href="https://clear.ml">ClearML</a>, and <a href="https://www.mlflow.org">MLFlow</a>.</p>

<h4 id="final-thoughts-on-fx">Final thoughts on \(f(x)\)</h4>
<p>The model category starts tackling problems from the model’s point of view. Answering a lot of the questions, such as how \(f(X)\) got created, when to use \(f(X)\), how to version \(f(X)\), and how we can expose \(f(X)\) to others.</p>

<p><img src="/assets/images/mlops/model.png" alt="An overview of some model-centric tools." /></p>

<p>However, we still have some huge questions to answer before we have a fully working ML system. Because, what is \(X\), and can \(X\) change how well our model performs?
Such questions lead us to the next category.</p>

<h3 id="input-data---x">Input data - \(X\)</h3>
<p>Introducing our second category - input data.</p>

<p>Someone jokingly said that ML practitioners use 99% of their time debugging and 1% writing code. However, there is some truth to this. 
It can be hard to debug ML products, and silent bugs can be one reason.</p>

<h4 id="an-example-of-a-silent-bug">An example of a silent bug</h4>
<p>To bring home this point, let’s look at a simple example.
Let’s say we have a ride-sharing company that wants to predict the duration of a ride. So the ML researcher finds some data in a database containing the ride’s distance. 
Therefore, thinking this can be a good feature. And the performance metrics were outstanding when evaluating using the train, test, and validation set. Thus, you are convinced to release a new model version.
However, we notice that our model performs way worse in production. How could this be?</p>

<p>We start debugging our data and notice that we estimate the distance when the ride starts but update our database with the actual duration when the ride has ended. 
Therefore, we have trained on the actual distance, but we predict an estimate. Thus, our model performance is worse in production. Such a bug is also known as data leakage.</p>

<p>Overwriting an estimate with the actual value may seem like a poor choice. Still, overwriting old data - known as slowly changing dimension 1 is common practice because it simplifies the queries in an application database. However, this makes making mistakes in ML products easier if we mutate data, as we may train a model on data from the future.</p>

<h4 id="data-warehouse">Data warehouse</h4>
<p>One solution and a common component in business analytics is a data warehouse.
A data warehouse is a single truth source for all analytical workloads. Making it optimal to compute aggregates on millions of records, and ML products can leverage this component.</p>

<p>Furthermore, since some data warehouses store a historical record of our data - potentially using slowly changing dimension 2, will it be possible to travel back in time. Therefore, it is possible to ask the question - what is \(X_{t}\) when \(t =\) the prediction time?</p>

<p>As an ML practitioners, this means we can create higher-quality datasets.
Let’s use the ride-sharing example again. We may store our estimated distance value and the overwritten actual distance in a data warehouse but with a different timestamp. 
By correctly crafting our queries, we can select the values available at prediction time, training on the estimated value, which is the same data we predict on. Therefore, the new training set increases the performance of our model in production, leading to a more significant value gain for our end-users.</p>

<p>However, crafting such queries can be complex, and it is easy to make mistakes. Therefore, an ML researcher would like to think about something other than this - so what can we do about this?</p>

<h4 id="feature-store">Feature store</h4>
<p>Here is where our feature store starts providing value.</p>

<p>A feature store acts as a highly specialized database for machine learning applications.
One such use case will be how to generate data sets that would be valid at the time of prediction. 
Therefore, fulfilling the same needs as described as a data warehouse. However, the feature store abstracts away the complex queries and logic needed to provide such data. Also known as a <em>Point-in-time correct join</em>.</p>

<p>Therefore, creating a dataset where each row has the features that were available at their prediction time \(D = \{X_{0,0}, X_{1,0}, ..., X_{t,e} \}\) where \(t\) is the prediction time, and \(e\) is the entity to predict for.</p>

<p>Furthermore, we often want to engineer our features a bit, either by generating embedding features or something simpler as computing a ratio between two columns.</p>

<p>We could add some pre-processing into an artifact and store them with our model in a model register. However, some features require more computing. For example, features like mean distance over 20 minutes will be impossible in a serverless pre-processing method.
But at the same time, we need to ensure that we use the same features in our training run and our online inference server. Otherwise, our prediction could be meaningless. This problem is also known as the <em>training-serving skew</em>.</p>

<p>Again, our second reason for a feature store. Ensure training and prediction features are the same \(X_{train} = X_{predict}\).</p>

<p>Lastly, there is one more reason to set up a feature store.
We have covered how a feature store leverages a data warehouse to generate higher-quality datasets. However, loading inference features from a data warehouse often lead to long waiting times. This latency is because a data warehouse is optimized to analyze large amounts. Therefore, a data warehouse optimizes its hardware and disk usage for such use cases. 
However, this often leads to slower reads when we ask for individual rows of data.</p>

<p>As a result, a feature store fixes this limitation by changing the data storage based on the query type. Therefore, using a data warehouse for generating large datasets, but then using a key-value store for low latency inference data.</p>

<p>Some alternatives for feature stores are <a href="https://feast.dev">Feast</a>, <a href="https://www.tecton.ai">Tecton</a>, <a href="https://www.hopsworks.ai">Hopsworks</a>, or my own solution <a href="https://github.com/otovo/aligned">Aligned</a>.</p>

<h4 id="final-thoughts-on-x">Final thoughts on \(X\)</h4>
<p>Ensuring data quality can be hard, as logical errors can easily creep in. Thankfully, a feature store can be a very useful component. Therefore, leveraging well-established data engineering practices abstracts away a lot of the complexity needed for ML products.</p>

<p><img src="/assets/images/mlops/input.png" alt="An overview of some data input tools." /></p>

<p>But still, there is one category left.</p>

<h3 id="output-data---y">Output data - \(y\)</h3>
<p>Lastly, we have the output data category. 
The output category could be in a joint data category with the input data. However, there are a few reasons for separating it into a separate category.</p>

<p>First of all, the leading players in the input data category do not provide a structured system to handle the output of our models.</p>

<p>Secondly, the output is the most critical artifact of them all, as this is what provides value for our end-users. All other components only exist to facilitate the creation of our \(y\) value.</p>

<p>However, more tooling is needed to make it easy to validate our online predictions.</p>

<h4 id="monitoring">Monitoring</h4>
<p>From my experience have, the most popular solution been to set up a <a href="https://prometheus.io">Prometheus</a> and <a href="https://grafana.com">Grafana</a> server for such use cases.</p>

<p>The Prometheus stack can work for simple needs. In the same way, S3 works well as a model registry when the artifact complexity is low.</p>

<p>However, ML-specific use cases can be hard to fulfill with such a stack. One such use case could be to view performance metrics based on sub-groups. This is very doable if we know the sub-group in advance, but it can lead to a low feedback loop if we want to check new sub-groups.</p>

<h4 id="evaluation-store">Evaluation store</h4>
<p>Here we have the evaluation store component. A component that specializes in monitoring online performance for ML products. 
However, this can sometimes be down prioritised to setup, because we may think training evaluation is good enough. But this will not always be the case, because of data drift, and silent data bugs.
Therefore, using an evaluation store uses ground truth to evaluate our online predictions \(y = \hat y\).</p>

<p>An evaluation store also enables viewing performance in sub-groups of interest, leading to a better understanding of our model’s limitations.</p>

<p>One tool here is <a href="https://gantry.io">Gantry</a>, but other players are also starting to pop up.</p>

<p>However, we have still on question left - why did the model return such an output?</p>

<h4 id="explanations">Explanations</h4>
<p>Finally, the last component we will cover, explainers.
Knowing why a model return an output can be very important for businesses. So important that businesses sometimes will release a less performant model that is explainable, rather than a more performant blackbox model.
Therefore, an explainer answers why \(\hat y\).</p>

<p>Here can a lot of approaches be used. Such as analysing the inner workings of a model - checking a decision-tree’s splits, but this will not work for complex models as neural nets. 
Such models are black boxes, resulting in a need for different tooling.
Thankfully, examples of useful tooling can be <a href="https://github.com/SeldonIO/alibi">alibi explain</a>, <a href="https://shap.readthedocs.io/en/latest/">SHAP</a>, and <a href="https://github.com/marcotcr/lime">LIME</a>.</p>

<h4 id="final-thoughts-on-y">Final thoughts on \(y\)</h4>
<p>The road to a fully functional ML product is long and complicated. However, online prediction evaluation can be an afterthought for ML products, even though online predictions are their most important artifact.
Therefore, potentially leading to solutions with more boilerplate code.</p>

<p><img src="/assets/images/mlops/output.png" alt="An overview of some data output tools." /></p>

<p>However, solutions like an evaluation store can help with this. 
Furthermore, tooling that explains our output can be crucial to provide the confidence and trust our ML products needs.</p>

<h2 id="wrap-up">Wrap up</h2>
<p>The amount of existing MLOps solutions is enormous, and this post only touches the tip of the iceberg. Concepts like drift detection and improving label data with active learning, to name a few.
However, we can broadly categorize MLOps solutions into three groups. Managing the input \(X\), the model \(f(X)\), or the output \(y\).</p>

<p>Furthermore, managing our models has become extremely easy, as noted by Chip Huyen</p>
<blockquote>
  <p>…, with models being increasingly commoditized, model development is often the easier part.</p>
</blockquote>

<p>However, managing our input data takes a lot of work, as it is easy to create faulty datasets. Furthermore, finding these data faults is hard. Thankfully, we have designed tools such as a feature store to reduce the number of potential flaws.</p>

<p>Lastly, managing our output data can quickly become an afterthought. Especially for companies that need more experience with ML products. As we quickly focus on the tooling making ML possible in the first place. 
Furthermore, the need for tooling to manage our model outputs can point to MLOps being immature.</p>

<p>However, many exciting MLOps solutions are trying to evolve the field further. One such tool is <a href="https://github.com/otovo/aligned">Aligned</a>, removing the separation between \(X\) and \(y\), and unifying it more as a data category. So follow me and the <a href="https://github.com/otovo/aligned">Aligned</a> repo for some exciting upcoming announcements!</p>

<p><img src="/assets/images/mlops/final.png" alt="An overview of the MLOps tooling landscape." /></p>]]></content><author><name>Mats Eikeland Mollestad</name><email>mats@mollestad.no</email></author><category term="MLOps" /><category term="Components" /><summary type="html"><![CDATA[Navigating the landscape of MLOps can be an overwhelming task. There are thousands of tools to explore, and understanding which problem each tool tackles can take time to grasp.]]></summary></entry></feed>