<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Handwriting-Recognition on Chris — freshteapot</title><link>https://freshteapot.net/tags/handwriting-recognition/</link><description>Chris makes things, ships them, and writes down what happened.</description><generator>Hugo 0.163.3</generator><language>en</language><atom:link href="https://freshteapot.net/tags/handwriting-recognition/" rel="self" type="application/rss+xml"/><item><title>Adding personal handwriting recognition to a small model</title><link>https://freshteapot.net/writing/personal-handwriting-recognition-small-model/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://freshteapot.net/writing/personal-handwriting-recognition-small-model/</guid><description>How I trained a small handwriting classifier, shipped it in a Flutter app, and taught it new characters from a few local samples.</description><content:encoded><![CDATA[<p><img src="/writing/personal-handwriting-recognition-small-model/recall-success-blog.webp" alt="A handwritten answer to the Norwegian prompt høy, recognised by Doodle Recall as a match." title="Personal handwriting recognition working in a Doodle Recall session" width="1125" height="1020" loading="lazy" decoding="async">
</p>
<p>Learnalist&rsquo;s handwriting recogniser could read an <code>A</code>. It could read a <code>6</code>. It could not read <code>Ø</code>, because <code>Ø</code> was not one of the classes it had been trained to recognise.</p>
<p>I could have expanded the dataset and trained another model. That would solve one character for everyone, but it would not solve the wider problem. People write the same character in different ways. A small model trained on a public dataset will eventually meet handwriting that does not look like its training data.</p>
<p>I wanted the app to learn from a handful of examples without uploading anyone&rsquo;s handwriting or training a neural network on their phone.</p>
<p>The result uses two layers of recognition:</p>
<ol>
<li>A small <a href="https://cs231n.github.io/convolutional-networks/">convolutional neural network</a> recognises common letters, digits and an equals sign.</li>
<li>A personal profile compares the model&rsquo;s internal representation of a new drawing with examples saved by the user.</li>
</ol>
<p>The second layer is called <strong>Train</strong> in the app, but it does not change the neural network&rsquo;s weights. It is closer to adding examples to a small local reference library.</p>
<p>This is the complete flow with <code>Ø</code>. The base model reads my first attempt as <code>O</code>. I add ten examples, switch back to Test, then write it twice more. Both new drawings are recognised as <code>Ø</code>.</p>
<p><a href="https://youtube.com/shorts/2nM4ESSKjH8">Watch the 54-second walkthrough on YouTube</a>. It opens with the failure and the result, then shows the complete test, training and retest flow.</p>
<p>The contact sheet makes the progression easier to scan: the initial failure,
the sample count rising to ten, then two successful tests.</p>
<p><img src="/writing/personal-handwriting-recognition-small-model/contact-sheet-selected-with-training.webp" alt="Selected frames from the handwriting recognition walkthrough, from the initial failed test through ten training samples to two successful tests." title="The complete training walkthrough as a contact sheet" width="540" height="828" loading="lazy" decoding="async">
</p>
<h2 id="the-project-that-made-me-try-it">The project that made me try it</h2>
<p>The idea started when I found <a href="https://github.com/Rkcr7/inkwordle">InkWordle</a>, a handwritten Wordle game for the reMarkable Paper Pro. You write each guess with the pen, and a small <a href="https://www.nist.gov/itl/products-and-services/emnist-dataset">EMNIST</a> model reads the letters on the device.</p>
<p>What caught my attention was the runtime. InkWordle uses <a href="https://github.com/sonos/tract">tract</a> to run its <a href="https://onnx.ai/">ONNX</a> model as native Rust code on the tablet. There is no server or Python process involved in recognition. That made me wonder whether the same approach would fit Doodle Recall, where people already write answers from memory inside Learnalist.</p>
<p>InkWordle gave me the push and tract gave me a practical route, but I did not copy its recogniser or model. InkWordle is licensed under GPLv3. I trained Learnalist&rsquo;s model independently and built a separate rasterisation, inference and personalisation pipeline for the app.</p>
<h2 id="training-the-base-model">Training the base model</h2>
<p>The base recogniser is a compact PyTorch model trained on EMNIST letters and digits. I also added synthetic equals signs, mostly to see whether I could extend a public handwriting dataset with a generated class.</p>
<p>The input is a single 28 by 28 greyscale image. Two convolution and pooling stages reduce it, then a 96-value hidden layer feeds the final classifier.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DoodleCnn</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">features</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Sequential</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Conv2d</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">16</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="n">padding</span><span class="o">=</span><span class="mi">1</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">ReLU</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">MaxPool2d</span><span class="p">(</span><span class="mi">2</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Conv2d</span><span class="p">(</span><span class="mi">16</span><span class="p">,</span> <span class="mi">32</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="n">padding</span><span class="o">=</span><span class="mi">1</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">ReLU</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">MaxPool2d</span><span class="p">(</span><span class="mi">2</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">classifier</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Sequential</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Flatten</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="mi">32</span> <span class="o">*</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">7</span><span class="p">,</span> <span class="mi">96</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">ReLU</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="mi">96</span><span class="p">,</span> <span class="mi">37</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span></code></pre></div><p>The 37 outputs cover <code>A</code> to <code>Z</code>, <code>0</code> to <code>9</code>, and <code>=</code>. This is deliberately narrow. The model only needs to support the short answers people draw during Doodle Recall. A general handwriting system would be larger and would solve a different problem.</p>
<p>The training data combines EMNIST&rsquo;s letter split, a balanced subset of its digits, and generated equals signs. The synthetic examples vary their width, spacing, angle, brightness and stroke thickness. The training script uses a fixed seed so I can reproduce a run and compare changes.</p>
<p>After training, I test against EMNIST&rsquo;s held-out test split and a separate set of generated equals signs.</p>
<p>That test is useful, but it only tells me how the model performs on data shaped like its training data. It does not tell me how well it understands a finger drawing captured by the app. I need a separate test for that.</p>
<h2 id="exporting-more-than-a-prediction">Exporting more than a prediction</h2>
<p>A normal classifier returns its scores for the available classes. For personal recognition, I also export the 96 values from the hidden layer immediately before the classifier.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExportModel</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="n">torch</span><span class="o">.</span><span class="n">Tensor</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">embedding</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">embedding</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">logits</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">classifier</span><span class="p">[</span><span class="mi">3</span><span class="p">](</span><span class="n">embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">logits</span><span class="p">,</span> <span class="n">embedding</span>
</span></span></code></pre></div><p>Those values form an <a href="https://developers.google.com/machine-learning/crash-course/embeddings/embedding-space"><strong>embedding</strong></a>. Drawings that the model considers similar should sit near each other in this 96-dimensional space, even when the final classifier gives them the wrong label.</p>
<p>This makes the base model useful for more than its original 37 classes. It has already learnt features such as curves, crossings, loops and stroke arrangements. The personal recogniser can reuse those features for a character the classifier has never seen.</p>
<p>The exported ONNX file is bundled with the app. Recognition stays on the device and works without a network connection.</p>
<h2 id="running-onnx-through-tract">Running ONNX through tract</h2>
<p>Learnalist is a Flutter app. A small Rust library loads the ONNX model with <a href="https://github.com/sonos/tract">tract</a> and exposes a C interface to Dart. Tract is a self-contained inference toolkit for TensorFlow and ONNX models, written in Rust.</p>
<p>The boundary is intentionally small. Dart supplies 784 floats and receives 37 logits plus the 96-value embedding.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="cp">#[unsafe(no_mangle)]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="k">unsafe</span><span class="w"> </span><span class="k">extern</span><span class="w"> </span><span class="s">&#34;C&#34;</span><span class="w"> </span><span class="k">fn</span> <span class="nf">doodle_recognition_predict_with_embedding</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">input</span>: <span class="o">*</span><span class="k">const</span><span class="w"> </span><span class="kt">f32</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">input_len</span>: <span class="kt">usize</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">output</span>: <span class="o">*</span><span class="k">mut</span><span class="w"> </span><span class="kt">f32</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">output_len</span>: <span class="kt">usize</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">embedding</span>: <span class="o">*</span><span class="k">mut</span><span class="w"> </span><span class="kt">f32</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">embedding_len</span>: <span class="kt">usize</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w"> </span>-&gt; <span class="kt">i32</span> <span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// Validate the buffers, run tract, then copy both outputs.
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Rust gives me a portable inference boundary for iOS, Android and any future desktop version. Python is only needed by the developer who trains and exports the shared model. It does not need to be installed on the user&rsquo;s device.</p>
<p>Before inference, Flutter converts the finger strokes into the same form the model saw during training. It finds the drawing bounds, preserves the aspect ratio, centres the glyph and rasterises it onto a 28 by 28 black canvas.</p>
<p>This preprocessing is part of the model contract. A change to centring, scale or stroke thickness can alter recognition even when the ONNX file stays the same.</p>
<h2 id="what-train-does-in-the-app">What Train does in the app</h2>
<p>The personal training screen asks the user to draw a character up to ten times. Each drawing passes through the normal rasteriser and base model. The app stores both the 28 by 28 raster and its normalised embedding.</p>
<p>These are the ten <code>Ø</code> examples at the moment I saved each one. The first frame
shows the empty canvas before training began.</p>
<p><img src="/writing/personal-handwriting-recognition-small-model/contact-sheet-selected_samples.webp" alt="The empty training canvas followed by ten handwritten Ø samples being added in Learnalist." title="Ten personal handwriting samples in the app" width="540" height="828" loading="lazy" decoding="async">
</p>
<p>The app does not store those screenshots. It centres and scales each drawing
onto the model&rsquo;s 28 by 28 input canvas. Here are the same ten samples from the
saved personal handwriting profile.</p>
<p><img src="/writing/personal-handwriting-recognition-small-model/personal-hw-raster-contact-sheet.webp" alt="Ten pixelated 28 by 28 greyscale rasters of handwritten Ø samples on black backgrounds." title="The same samples after rasterisation" width="1520" height="672" loading="lazy" decoding="async">
</p>
<p>For each character, it calculates the mean embedding of the saved samples. This mean is the character&rsquo;s centroid.</p>
<p>When a new drawing arrives, the app compares its embedding with every personal centroid using cosine similarity. The closest result is accepted only when it is strong enough and clearly better than the runner-up.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="cl"><span class="kd">final</span> <span class="n">scores</span> <span class="o">=</span> <span class="o">&lt;</span><span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="kt">double</span><span class="p">)</span><span class="o">&gt;</span><span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">entry</span> <span class="k">in</span> <span class="n">samples</span><span class="p">.</span><span class="n">entries</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kd">final</span> <span class="n">centroid</span> <span class="o">=</span> <span class="n">mean</span><span class="p">(</span><span class="n">entry</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">sample</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">sample</span><span class="p">.</span><span class="n">embedding</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="n">scores</span><span class="p">.</span><span class="n">add</span><span class="p">((</span><span class="n">entry</span><span class="p">.</span><span class="n">key</span><span class="p">,</span> <span class="n">dot</span><span class="p">(</span><span class="n">normalisedInput</span><span class="p">,</span> <span class="n">centroid</span><span class="p">)));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">scores</span><span class="p">.</span><span class="n">sort</span><span class="p">((</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">b</span><span class="p">.</span><span class="n">$2</span><span class="p">.</span><span class="n">compareTo</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">$2</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">bestScore</span> <span class="o">&lt;</span> <span class="n">threshold</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">bestScore</span> <span class="o">-</span> <span class="n">runnerUpScore</span> <span class="o">&lt;</span> <span class="m">0.04</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">PersonalHandwritingMatch</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nl">character:</span> <span class="n">bestCharacter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nl">similarity:</span> <span class="n">bestScore</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span></code></pre></div><p>Rejecting an uncertain result matters. A personal profile should improve the base recogniser, not override every prediction because one saved example happens to be nearest.</p>
<p>The threshold becomes more useful as samples are added. With one example, the app requires a very close match. With several examples, it looks at how consistent the saved samples are and sets the threshold from that cluster, with a conservative lower bound.</p>
<p>If no personal result passes these checks, the app keeps the base model&rsquo;s answer.</p>
<h2 id="why-i-keep-the-original-rasters">Why I keep the original rasters</h2>
<p>The personal profile stores embeddings, but embeddings belong to a particular model and preprocessing pipeline. If I replace the base model or change how a glyph is rasterised, old embeddings may no longer be comparable with new ones.</p>
<p>Keeping the 28 by 28 rasters provides a migration path. The app versions the profile against the model and preprocessing code. When either version changes, it can run the saved rasters through the new model and rebuild the embeddings locally.</p>
<p>The profile also records which account owns it. It can be reset, individual samples can be removed, and custom characters remain visible even before their first sample is collected.</p>
<p>These details are less interesting than the similarity calculation, but they make the feature safe to ship rather than merely convincing in a prototype.</p>
<h2 id="testing-my-own-handwriting">Testing my own handwriting</h2>
<p>I recorded a small test with <code>Ø</code>, a useful Norwegian character that the base classifier cannot return.</p>
<p>Before training, the model read my drawing as <code>O</code> with 25.7% confidence. Its other leading candidates were <code>B</code> at 25.3% and <code>0</code> at 18.8%. There was no personal result because I had not saved any <code>Ø</code> samples.</p>
<p><img src="/writing/personal-handwriting-recognition-small-model/contact-sheet-selected-before-training.webp" alt="Opening personal handwriting training, selecting Ø, drawing it in Test, and seeing the base model recognise it as O." title="The failed test before personal training" width="540" height="556" loading="lazy" decoding="async">
</p>
<p>I added ten examples, then made five new test drawings. The personal threshold was 0.9000.</p>
<table>
	<thead>
			<tr>
					<th>Test</th>
					<th style="text-align: right">Base result</th>
					<th style="text-align: right">Personal similarity</th>
					<th>Decision</th>
					<th style="text-align: right">Final result</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td style="text-align: right"><code>Q</code> at 45.5%</td>
					<td style="text-align: right">0.9537</td>
					<td>accepted</td>
					<td style="text-align: right"><code>Ø</code></td>
			</tr>
			<tr>
					<td>2</td>
					<td style="text-align: right"><code>Q</code> at 56.4%</td>
					<td style="text-align: right">0.9654</td>
					<td>accepted</td>
					<td style="text-align: right"><code>Ø</code></td>
			</tr>
			<tr>
					<td>3</td>
					<td style="text-align: right"><code>Q</code> at 42.5%</td>
					<td style="text-align: right">0.8895</td>
					<td>below threshold</td>
					<td style="text-align: right"><code>Q</code></td>
			</tr>
			<tr>
					<td>4</td>
					<td style="text-align: right"><code>A</code> at 42.7%</td>
					<td style="text-align: right">0.8814</td>
					<td>below threshold</td>
					<td style="text-align: right"><code>A</code></td>
			</tr>
			<tr>
					<td>5</td>
					<td style="text-align: right"><code>8</code> at 39.2%</td>
					<td style="text-align: right">0.9404</td>
					<td>accepted</td>
					<td style="text-align: right"><code>Ø</code></td>
			</tr>
	</tbody>
</table>
<p>Three of the five personal matches were accepted. The other two were close to <code>Ø</code>, but remained below the threshold. The app kept the base result instead of forcing a weak personal match.</p>
<p>The video ends after the first two successful tests. I kept it short because it demonstrates the feature clearly. The table comes from the complete device log and includes the three later attempts.</p>
<p>This is a test of one character written by one person. It is not a benchmark for handwriting recognition in general. It answers a narrower product question: can a few local examples correct a real failure while rejecting uncertain matches?</p>
<p>The app also has separate Train and Test views. Train saves a drawing. Test recognises it without saving it, then shows the result. Keeping those actions separate helps prevent an apparently successful test from quietly becoming another training example.</p>
<h2 id="a-useful-middle-ground">A useful middle ground</h2>
<p>Full custom training is possible. I could train a new classification head on the device or adopt a framework designed for on-device learning. That brings more code, more failure modes and more decisions about when and how training runs.</p>
<p>Embedding comparison gave me a smaller first step. The shared model does the expensive work of learning useful handwriting features. Each user supplies a few labelled examples. A dot product and several conservative checks adapt the result to their handwriting.</p>
<p>This approach will not replace full training in every application. It works here because each personal class has few examples, inference needs to be quick, and the app can safely fall back to its bundled classifier.</p>
<p>The broader lesson is to inspect what a small model already knows before training another one. Its final prediction is only one output. The representation behind that prediction may be enough to build the custom behaviour your product needs.</p>
<p><a href="https://apps.apple.com/app/id1558923358">Learnalist</a> uses Doodle Recall to help people practise words, facts and short answers by writing them from memory.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://apps.apple.com/app/id1558923358">Learnalist on the App Store</a></li>
<li><a href="https://learnalist.net/features/mobile-learnalist-v1.html">Learnalist mobile app</a></li>
<li><a href="https://youtube.com/shorts/2nM4ESSKjH8">Handwriting recognition walkthrough</a></li>
<li><a href="https://cs231n.github.io/convolutional-networks/">Convolutional Neural Networks, Stanford CS231n</a></li>
<li><a href="https://www.nist.gov/itl/products-and-services/emnist-dataset">EMNIST dataset and downloads, NIST</a></li>
<li><a href="https://developers.google.com/machine-learning/crash-course/embeddings/embedding-space">Embedding space, Google for Developers</a></li>
<li><a href="https://github.com/Rkcr7/inkwordle">InkWordle</a></li>
<li><a href="https://github.com/sonos/tract">tract</a></li>
<li><a href="https://onnx.ai/">ONNX is an open format built to represent machine learning models</a></li>
</ul>
]]></content:encoded></item></channel></rss>