<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://hugovergnes.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://hugovergnes.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-09-07T02:51:35+00:00</updated><id>https://hugovergnes.github.io/feed.xml</id><title type="html">Hugo Vergnes</title><subtitle>Machine learning engineer and researcher. I train and evaluate deep-learning systems, from language models pretrained from scratch to video understanding. </subtitle><entry><title type="html">Training a 3.8B LLM to 0.384 CORE for $998</title><link href="https://hugovergnes.github.io/little-lm-3-8b/" rel="alternate" type="text/html" title="Training a 3.8B LLM to 0.384 CORE for $998"/><published>2026-09-04T00:00:00+00:00</published><updated>2026-09-04T00:00:00+00:00</updated><id>https://hugovergnes.github.io/little-lm-3-8b</id><content type="html" xml:base="https://hugovergnes.github.io/little-lm-3-8b/"><![CDATA[<p>Somewhere between “nanoGPT toy” and “you need a research lab” there’s a large, under-described region where one person with a few thousand dollars can train a meaningful model.</p> <p>I wanted to see language and understanding emerge from random weights for myself, and to learn the parts you can only learn by starting from scratch. This project was written in the evenings, debugged on a 5090 and finished on rented B200s. It was heavily inspired by Andrej Karpathy’s <a href="https://github.com/karpathy/nanochat">nanochat</a>.</p> <p>The result is a 3.8B-parameter model scoring <strong>0.384 on CORE</strong>, trained on 65B tokens in 43 hours for <strong>$998</strong>.</p> <p>What follows is what worked, what didn’t, and what I still don’t know.</p> <table> <thead> <tr> <th>Model</th> <th>Params</th> <th>Tokens</th> <th>Hardware</th> <th>Time</th> <th>Cost</th> <th>CORE</th> </tr> </thead> <tbody> <tr> <td>GPT-2 (OpenAI)</td> <td>1.5B</td> <td>—</td> <td>—</td> <td>—</td> <td>—</td> <td>0.2565</td> </tr> <tr> <td>nanochat d26</td> <td>~561M</td> <td>11.2B</td> <td>8× H100</td> <td>~3h</td> <td>—</td> <td>~0.258</td> </tr> <tr> <td>nanochat d32</td> <td>~1B</td> <td>—</td> <td>8× H100</td> <td>~33h</td> <td>~$1000</td> <td>0.310</td> </tr> <tr> <td>little-lm 3.8B (1024 ctx)</td> <td>3.848B</td> <td>57.3B</td> <td>8× B200</td> <td>35.9h</td> <td>$820</td> <td>0.338</td> </tr> <tr> <td><strong>little-lm 3.8B (2048 ctx)</strong></td> <td><strong>3.848B</strong></td> <td><strong>65.3B</strong></td> <td><strong>8× B200</strong></td> <td><strong>43h</strong></td> <td><strong>$998</strong></td> <td><strong>0.384</strong></td> </tr> </tbody> </table> <p>My model is larger than nanochat d32 and took similar wall-clock time. B200s were better value per unit of work than H100s. But for roughly the same money as nanochat’s $1,000 configuration, this lands meaningfully ahead of it. An encouraging data point about what’s reachable outside a lab or a mega company with millions in compute budget. As the frontier moves, $1,000 takes you further and further.</p> <hr/> <h2 id="setup">Setup</h2> <p>I’ve built little-lm as a config-driven framework for training small decoder-only LLMs. Every run is fully specified by a YAML file: model, dataset, optimizer, schedule, callbacks. Components self-register into a global registry and get resolved by name, so swapping an optimizer or a dataset is a one-line config change.</p> <p><strong>Good infrastructure pays for itself almost immediately.</strong> Ordinary software engineering discipline (Things like separation of concerns, clean interfaces, components you can swap in) matters a lot in AI work. It cost me a little at the start, and a couple more times afterward to fix bad contracts or suboptimalities. But this time investment pays for itself at the first convergence problem you encounter. I found that a great infra is the infra that almost never requires you to edit code manually. If you can read the config and understand exactly what happens, and there are no hidden mechanics, it means you have done a good job. The following report is the result of being able to express experiments as a three-line YAML diff rather than a branch.</p> <p>The final model is Llama-style: RMSNorm, RoPE, GQA (24 query heads, 8 KV heads), relu² MLPs, QK-norm, logit softcap, per-layer learnable residual scalars, and ResFormer-style value embeddings.</p> <table> <thead> <tr> <th>Component</th> <th>Params</th> </tr> </thead> <tbody> <tr> <td>Token embeddings</td> <td>154.5M</td> </tr> <tr> <td>LM head (untied)</td> <td>154.5M</td> </tr> <tr> <td>28 decoder layers</td> <td>2,818.7M</td> </tr> <tr> <td>Value embeddings (14 tables)</td> <td>721.2M</td> </tr> <tr> <td><strong>Total</strong></td> <td><strong>3.848B</strong></td> </tr> </tbody> </table> <p>Worth noting that the value embeddings are 19% of the parameter count. 14 tables of <code class="language-plaintext highlighter-rouge">vocab × kv_dim</code>, one on every other layer.</p> <hr/> <h2 id="results">Results</h2> <h3 id="early-experiments">Early experiments</h3> <p>Before good runs there were many bad ones.</p> <p>I trained an 858M Llama on FineWeb-Edu for 16.4B tokens, 5.8 days on a single A100. AdamW at 2.5e-4, cosine decay to zero, 5% warmup, batch 256 via gradient accumulation, 2048 context.</p> <p>The result: <strong>PIQA 60.45%</strong>. GPT-2 124M scores about 63%. I had spent six days of compute to build something worse than a model seven times smaller, from 2019. Generations were repetitive and borderline nonsensical.</p> <p>The loss curve told the story.</p> <ol> <li><strong>Cosine decay to zero.</strong> The curve went completely flat after about 70% of the steps. The final 30% of the compute budget produced essentially nothing as the learning rate might be too low. Linear cooldown holds a useful rate much later.</li> <li><strong>Peak LR too conservative.</strong> 2.5e-4 is low for 858M parameters. You can be quite aggressive for those small models.</li> <li><strong>AdamW on everything.</strong> Muon should be meaningfully better per-token for the matrix parameters at this scale. In fact this was demonstrated pretty quickly in ablation runs.</li> <li><strong>The data.</strong> FineWeb-Edu is decent. It is not the best available.</li> </ol> <p>Five changes came out of that post-mortem. Together they are the difference between the run above and a model that beats GPT-2 by a wide margin.</p> <p><strong>Trapezoidal LR schedule.</strong> Warmup for 5%. Hold flat and finish with linear cooldown over the last 50% to 5% of peak. The point is that the model keeps learning until the end instead of coasting through the tail. In the 3.8B run the eval loss was <em>still descending</em> at the final step, which is exactly the behavior the 858M run failed to produce.</p> <p><strong>Muon for matrix parameters</strong>, AdamW for everything else. Muon is slower per step (Newton-Schulz orthogonalization isn’t free, about 25% in a shallow-accumulation benchmark) but that cost is paid once per optimizer step: at 7 gradient-accumulation steps it dilutes to ~4%. Measured against total run time the convergence is much faster overall.</p> <p><strong>ClimbMix instead of FineWeb-Edu.</strong> This was a tremendous jump in convergence speed. Exactly as Karpathy found as well.</p> <p><strong>FP8 + vocab padding.</strong> FP8 training via <code class="language-plaintext highlighter-rouge">torch._scaled_mm</code> with dynamic tensorwise scaling on all three GEMMs, and padding the vocab from 50,257 to 50,304 (a multiple of 64) so the tensor cores are happy. Together, +33% throughput mostly from fp8.</p> <p><strong>1024 context instead of 2048.</strong> Halving the context roughly doubles the batch size at fixed memory. Throughput barely changes per token. We are still dominated by the MLPs which is a good sign we are using the hardware effectively. Below we will discuss the impact of the context length on the model.</p> <p>Here is the whole run:</p> <table> <thead> <tr> <th>Step</th> <th>Tokens</th> <th>Eval loss</th> <th>CORE</th> </tr> </thead> <tbody> <tr> <td>2,500</td> <td>5.7B</td> <td>2.3278</td> <td>0.2389</td> </tr> <tr> <td>5,000</td> <td>11.5B</td> <td>2.2072</td> <td>0.2752</td> </tr> <tr> <td>7,500</td> <td>17.2B</td> <td>2.1571</td> <td>0.2934</td> </tr> <tr> <td>10,000</td> <td>22.9B</td> <td>2.1269</td> <td>0.3104</td> </tr> <tr> <td>12,500</td> <td>28.7B</td> <td>2.1075</td> <td>0.3147</td> </tr> <tr> <td>15,000</td> <td>34.4B</td> <td>2.0710</td> <td>0.3224</td> </tr> <tr> <td>17,500</td> <td>40.1B</td> <td>2.0395</td> <td>0.3294</td> </tr> <tr> <td>20,000</td> <td>45.9B</td> <td>2.0160</td> <td>0.3267</td> </tr> <tr> <td>22,500</td> <td>51.6B</td> <td>1.9963</td> <td>0.3345</td> </tr> <tr> <td><strong>25,000</strong></td> <td><strong>57.3B</strong></td> <td><strong>1.9868</strong></td> <td><strong>0.3384</strong></td> </tr> </tbody> </table> <p>~480,000 tokens/sec in steady state, which puts 57.3B tokens at 33 hours. The wall clock was 35.9h. The difference is the CORE evaluations, which took about 15 minutes each (ten of them over the run) and consumed 7% of the total.</p> <p>Re-running this identical recipe at 2048-token context scored <strong>0.3840</strong>. Almost all of that gap turned out to be some tasks that were very context dependent.</p> <p>On the GPUs themselves: <strong>92% SM activity, 40% SM occupancy.</strong> High activity means the SMs almost never went idle. No dataloader starvation or network waits, which is the payoff for downloading the shards locally instead of streaming, which would leave us vulnerable to a small hugging face network hang. The low occupancy is what back-to-back large GEMMs look like: matmul kernels trade occupancy for register-tile size on purpose. Compute-bound and well fed, great signal we are using the hardware well and we can extend every dollar we spend into a better model.</p> <p>That’s about <strong>1,047 TFLOP/s sustained per B200</strong>, or <strong>~25% MFU</strong> against Blackwell’s dense FP8 peak. (Against the bf16 peak it reads as 50%, which is the number that matters a bit more because not even all the linear layers run in FP8.)</p> <p>The distributed strategy is plain old DistributedDataParallel. At 3.8B on a single node, gradient communication was never the constraint, and the sharded-optimizer machinery turned out to be unnecessary.</p> <h3 id="increasing-throughput">Increasing throughput</h3> <p>Renting GPUs isn’t cheap, at work you often think about the quality of the model before its cost. When it’s your own money burning, throughput matters a lot more all of a sudden.</p> <p>This took real work on a single RTX 5090, before I ever rented a node. Baseline 858M model, bf16, compiled: <strong>26,144 tok/s</strong>. Final: <strong>37,621 tok/s</strong>.</p> <p><strong>FP8 (+25%).</strong> All three GEMMs (1 forward and 2 backwards) in FP8 with dynamic tensorwise scaling. Requires SM90+ but that is quite a nice throughput jump.</p> <p><strong>Vocab padding (+33% cumulative).</strong> Padding 50,257 → 50,304 costs 47 unused embedding rows and unlocks the fast tensor-core path. Nearly free.</p> <p><strong>Fused linear cross-entropy (+44% cumulative).</strong> Liger’s <code class="language-plaintext highlighter-rouge">FusedLinearCrossEntropyLoss</code> fuses the lm_head matmul into the loss and chunks internally, so the full <code class="language-plaintext highlighter-rouge">(B*T, vocab)</code> logits tensor is never materialized. Measured head-to-head at the same batch size it is <strong>6% slower</strong>:</p> <table> <thead> <tr> <th>Config</th> <th>Throughput</th> <th>VRAM</th> </tr> </thead> <tbody> <tr> <td>Baseline CE, batch 6</td> <td>34,724 tok/s</td> <td>27,852 MiB</td> </tr> <tr> <td>Fused CE, batch 6</td> <td>32,952 tok/s</td> <td>19,630 MiB</td> </tr> <tr> <td>Fused CE, batch 8</td> <td>35,979 tok/s</td> <td>24,028 MiB</td> </tr> <tr> <td>Fused CE, batch 10</td> <td><strong>37,621 tok/s</strong></td> <td>28,872 MiB</td> </tr> </tbody> </table> <p>Even though it’s slower per step, it buys back a good amount of VRAM (8 GB on my 5090) so the increase in micro-batch size more than makes up for the lost 6%. Claude was quick to reject it because it was 6% lower, but overall it was a great way to claw some extra throughput.</p> <p><strong>Non-gated MLPs.</strong> Dropping the gate projection (SwiGLU → relu², two matmuls instead of three) on the small model: 183,035 → 214,173 tok/s and 6 GB less VRAM. One caveat from the ablations: a SwiGLU intermediate ratio of 2.75 does <em>not</em> transfer to relu². The model learns noticeably worse. Use 4× for non-gated.</p> <p><strong>bf16 master weights.</strong> Keeping the optimizer master weights in bf16 rather than fp32 cut VRAM 27% and raised throughput from 640K to 1.4M tok/s on the 1.5B config. That was a huge speed-up, 2.2×. The quality cost is real but small: CORE 0.22 vs 0.23 at 4,000 steps. When you’re optimizing for capability per dollar, careful dtype handling is one of the highest-leverage and underdiscussed knobs available.</p> <p><strong>Hardware.</strong> Same code, 150M model, FP8: RTX 5090 at 184,662 tok/s, B200 at 477,440 tok/s. 2.59× from hardware alone, before accounting for the extra VRAM letting you push batch size further.</p> <h3 id="what-didnt-work">What didn’t work</h3> <p><strong>Document-boundary masking with flex attention.</strong> Packing documents into one sequence lets tokens attend across boundaries, so I fixed it properly: per-token document IDs and mask out attention so each token can only attend to its current document. It was elegant, but I deleted all of it. Andrej Karpathy also found that cross-document leakage does not make things much worse under BOS-aligned packing. Best-fit packing replaced it in ~10 lines, and attention went back to an unconditional <code class="language-plaintext highlighter-rouge">F.scaled_dot_product_attention(..., is_causal=True)</code>. I believe this is also conditional on the dataset and the training documents.</p> <p><strong>Liger RMSNorm and RoPE.</strong> RoPE was 2.2× faster in a microbenchmark and produced <em>no measurable change</em> in end-to-end throughput. RoPE is not part of the critical compute bottleneck at this scale. RMSNorm was outright slower than PyTorch 2.9’s built-in <code class="language-plaintext highlighter-rouge">F.rms_norm</code> (0.41ms vs 0.25ms). Both reverted, not worth the complexity.</p> <p><strong>Nanochat-style initialization.</strong> Embeddings at <code class="language-plaintext highlighter-rouge">N(0, 0.8)</code>, linear weights uniform, output projections zero-initialized so the residual stream starts as pure identity, LM head at <code class="language-plaintext highlighter-rouge">N(0, 0.001)</code>. Theoretically much nicer than GPT-2’s <code class="language-plaintext highlighter-rouge">N(0, 0.02)</code> everywhere. The loss curve starts marginally lower and the two curves overlap by ~1,500 steps. No measurable quality difference. I kept it, but for aesthetics, not evidence.</p> <p><strong>Streaming datasets.</strong> Great for getting started, wrong for a real run. Even when the network looks healthy, local shards gave 2-3% more throughput, and occasional network dips cost far more than that. For runs longer than a few hours, it’s worth it to pay the download once at the start of training.</p> <h3 id="ablation-on-value-embedding">Ablation on value-embedding</h3> <p>Value embeddings were 721M parameters for a 3.8B model. I trained the same model with the same config with <code class="language-plaintext highlighter-rouge">value_embeddings: false</code> and compared it against the original run, which I’d already paid for, out to 12,500 steps and 29B tokens.</p> <table> <thead> <tr> <th> </th> <th>Params</th> <th>Loss @12.5K</th> <th>CORE @12.5K</th> <th>Throughput</th> </tr> </thead> <tbody> <tr> <td>Value embeddings on</td> <td>3.848B</td> <td><strong>2.1075</strong></td> <td><strong>0.3147</strong></td> <td>479,445 tok/s</td> </tr> <tr> <td>Value embeddings off</td> <td>3.128B</td> <td>2.1171</td> <td>0.3047</td> <td>477,908 tok/s</td> </tr> </tbody> </table> <p>0.46% better loss and 3.2% better CORE, for 19% more parameters. The throughput is identical, because value embeddings are lookups. They cost memory and optimizer state but essentially no FLOPs.</p> <p>Two interesting findings:</p> <ul> <li><strong>Value embeddings bought the equivalent of about 1,200 training steps.</strong> Here is how to price that: between steps 10,000 and 12,500 my baseline loss fell 0.0194, so 2,500 steps buys roughly that much. The value-embedding advantage is 0.0096, about half of it — call it 1,200 steps out of 25,000. So 19% more parameters is worth ~5% more training.</li> <li><strong>CORE moved about seven times more than loss did</strong> (3.2% vs 0.46%), and the gap shrank steadily during training. That’s worth knowing if you’re using CORE to make decisions: it’s an accuracy metric, so items near the decision boundary flip on tiny logit changes, and it’s centered against a random baseline, which amplifies relative differences while scores are still low.</li> </ul> <p>Value embeddings are useful for a small model and come at almost no throughput cost. Spending a little bit of VRAM on this gives the model a form of bias toward certain concepts that might be useful for CORE.</p> <hr/> <h2 id="discussion">Discussion</h2> <h3 id="misleading-micro-benchmarks">Misleading micro-benchmarks</h3> <p>We could be tempted to believe that 1024 tokens context is plenty for a high CORE score. Going back through the per-task logs, that conclusion is wrong on some tasks that are very context sensitive.</p> <p>3 of the 22 CORE tasks have prompts that essentially never fit in 1024 tokens:</p> <table> <thead> <tr> <th>Task</th> <th>Prompts cropped</th> <th>Step 2.5K</th> <th>Step 25K</th> </tr> </thead> <tbody> <tr> <td>squad</td> <td>10570 / 10570 (100%)</td> <td>0.1478</td> <td><strong>0.0000</strong></td> </tr> <tr> <td>boolq</td> <td>3265 / 3270 (99.8%)</td> <td>0.5798</td> <td>0.5131</td> </tr> <tr> <td>bigbench_language_id</td> <td>9965 / 10000 (99.7%)</td> <td>0.2454</td> <td>0.2538</td> </tr> </tbody> </table> <p>SQuAD is the striking one. It doesn’t stagnate, it decays monotonically to exactly zero: 0.1478 → 0.0617 → 0.0099 → 0.0007 → 0.0000. The model gets steadily <em>worse</em> at this task the longer it trains, which is not a thing models normally do.</p> <p>Two details explain it. SQuAD is a <strong>10-shot</strong> task in the DCLM bundle, so each prompt is ten worked examples followed by the real one. Median of 1,998 tokens on my eval data. Not one fits in 1024. And when a prompt is too long my harness keeps the <em>last</em> <code class="language-plaintext highlighter-rouge">max_seq_len</code> tokens.</p> <p>The test passage sits at the end, so it always survived; a test example is only ~169 tokens. What got truncated was the ten demonstrations. The model was reading the passage and the question, and almost never seeing the examples that teach it the expected output format. Since SQuAD is scored on exact-token match against the gold answer, fluent prose scores zero every time.</p> <p>That also explains the decline. An early, high-entropy model occasionally emits something short and generic that happens to match. As it sharpens it commits to well-formed continuations, and the accidental hits disappear. Funnily enough, getting better at language made it worse at guessing right by accident.</p> <p>boolq shows a gentler version of the same shape. It peaks at step 10,000 (0.6294) and declines to 0.5131. Language identification never moves off chance at all.</p> <p>In short, <strong>0.338 was measured with three of 22 tasks scoring near-zero for reasons that have nothing to do with model quality, just the size of the context length being fed to it.</strong></p> <h3 id="the-effect-of-larger-context">The effect of larger context</h3> <p>As we have seen, if we want the highest CORE score possible we need larger context. But this has consequences on the training throughput.</p> <p>Double the context length, halve micro-batch to hold VRAM constant, so tokens per optimizer step stayed identical. I stopped it at ~28,000 steps to save the last few hours of rental, so the learning-rate warmdown never fully completed and the number below is a lower bound.</p> <p><strong>CORE went from 0.3384 to 0.3840.</strong></p> <p><img src="/assets/img/fig_core_vs_loss.svg" alt="Eval loss and CORE for the 1024 and 2048 context runs"/></p> <p>At step 20,000 the two runs have the same eval loss to four decimal places (2.0160 vs 2.0164) and differ by 0.034 on CORE. It was surprising to see that low level of correlation between CORE and eval loss on the ClimbMix dataset.</p> <table> <thead> <tr> <th>Task</th> <th>1024</th> <th>2048</th> <th>Cropped</th> </tr> </thead> <tbody> <tr> <td>squad</td> <td>0.0000</td> <td><strong>0.3114</strong></td> <td>100% → 47%</td> </tr> <tr> <td>boolq</td> <td>0.5131</td> <td><strong>0.7095</strong></td> <td>99.8% → 3.2%</td> </tr> <tr> <td>bigbench_language_id</td> <td>0.2538</td> <td>0.2585</td> <td>99.7% → 14%</td> </tr> <tr> <td><em>the other 19 tasks</em></td> <td> </td> <td> </td> <td><em>+0.008 combined</em></td> </tr> </tbody> </table> <p><strong>squad and boolq alone are 83% of the gain.</strong> boolq contributes the most, because its random baseline is 0.5 and CORE centers against that: a raw +0.196 becomes a centered +0.517. Strip those two and the remaining twenty move +0.008 in total, roughly what 14% more tokens buys on its own.</p> <p>Language identification went from 99.7% cropped to 14% cropped and moved +0.005. This is by far the hardest task in the CORE evaluation benchmark for our current model.</p> <p>A couple of tasks got worse: commonsense_qa dropped 0.072, cs_algorithms 0.031. Across 22 tasks some movement in both directions is expected.</p> <p>2048 was worth paying for as a measurement decision, not a quality one. It cost 9% throughput (480K → 437K tok/s), and outside the tasks that couldn’t be scored at 1024 it bought almost nothing. 1024 is fine for training and a “cheap” way of getting your model to a good CORE score. 2048 unlocks some tasks that are very context bound.</p> <hr/> <h2 id="future-work">Future work</h2> <h3 id="limitations">Limitations</h3> <p>Four things I never ablated. Peak LR, from nanochat’s <code class="language-plaintext highlighter-rouge">sqrt(768/d_model)</code>. I didn’t really want to spend money to sweep learning rates. I moved from cosine to trapezoidal because of the 858M post-mortem, there could be schedules out there that are more efficient. QK-norm, on by default and never toggled off. And the GQA ratio, since it’s a nice lever to save on memory.</p> <p>Most of those are inherited from nanochat rather than tested here. That is a defensible way to spend a small budget — someone else already paid for the experiment — but it means I am trusting that Karpathy’s results transfer to my model, data and scale.</p> <h3 id="open-questions">Open questions</h3> <p>There is a lot of interesting work I’d want to pursue if I had more time and resources:</p> <ul> <li><strong>Value embeddings versus reallocation.</strong> The comparison above was VE against nothing. The one that matters is VE against spending those 721M on something else.</li> <li><strong>1024 versus 2048 at matched wall-clock.</strong> The rerun changed context <em>and</em> ran longer, so it settles the measurement question but not the quality one.</li> <li><strong>Why commonsense_qa regressed</strong> by 0.072 at the longer context, when nothing about that task involves long prompts.</li> <li><strong>Sharding the optimizer, the way nanochat does.</strong> I used plain DDP with a single-GPU Muon, which means every rank holds a full copy of the optimizer state <em>and</em> redundantly recomputes the same Newton-Schulz update. nanochat drops the DDP wrapper entirely and does ZeRO-2 sharding inside the optimizer, overlapping reduce-scatter, compute and all-gather. The memory win is the certain one, and freed memory turns into batch size, which is tokens for the same dollars. Whether the redundant orthogonalization also goes away depends on how the sharding is done: Muon needs the full gradient matrix, so splitting a matrix across ranks doesn’t help, while giving each rank whole matrices of its own would. I haven’t explored that at all but I think it would be a great way to further increase the total training throughput at the cost of some extra machinery.</li> <li><strong>Additional data exploration.</strong> I haven’t had a lot of time for data analysis on either the CORE benchmark or the ClimbMix dataset. I’m sure this would help us claw even higher performance with the same compute budget.</li> </ul> <hr/> <h2 id="closing-thought">Closing thought</h2> <p>GPT-2 was a frontier result in 2019, produced by a well-funded lab with a large team, and its 1.5B model scores 0.2565 on CORE. 7 years later I beat that by a wide margin in my evenings, for $998, on hardware I rented by the hour.</p> <p>The frontier moved, and everything came with it. Work that needed a lab can now be done by a single engineer in the evenings. I wonder what kind of insane machine we will be able to build in 7 years from now!</p> <hr/> <h2 id="appendix-the-config">Appendix: the config</h2> <p>The whole run, flattened from the YAML includes into one block.</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">model</span><span class="pi">:</span>
  <span class="na">hidden_size</span><span class="pi">:</span> <span class="m">3072</span>
  <span class="na">intermediate_size</span><span class="pi">:</span> <span class="m">12288</span>      <span class="c1"># 4x, non-gated</span>
  <span class="na">num_hidden_layers</span><span class="pi">:</span> <span class="m">28</span>
  <span class="na">num_attention_heads</span><span class="pi">:</span> <span class="m">24</span>
  <span class="na">num_key_value_heads</span><span class="pi">:</span> <span class="m">8</span>        <span class="c1"># 3:1 GQA</span>
  <span class="na">head_dim</span><span class="pi">:</span> <span class="m">128</span>
  <span class="na">hidden_act</span><span class="pi">:</span> <span class="s">relu2</span>
  <span class="na">gated_mlp</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">qk_norm</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">logit_softcap</span><span class="pi">:</span> <span class="m">15.0</span>
  <span class="na">layer_scale</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">value_embeddings</span><span class="pi">:</span> <span class="kc">true</span>        <span class="c1"># 14 tables, alternating layers</span>
  <span class="na">tie_word_embeddings</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">rope_theta</span><span class="pi">:</span> <span class="m">10000.0</span>
  <span class="na">rms_norm_eps</span><span class="pi">:</span> <span class="s">1.0e-6</span>
  <span class="na">vocab_pad_to</span><span class="pi">:</span> <span class="m">64</span>              <span class="c1"># 50257 -&gt; 50304</span>
  <span class="na">max_position_embeddings</span><span class="pi">:</span> <span class="m">2048</span>
  <span class="na">dtype</span><span class="pi">:</span> <span class="s">bf16</span>

<span class="na">engine</span><span class="pi">:</span>
  <span class="na">compile</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">fp8</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">precision</span><span class="pi">:</span> <span class="s">bf16</span>
  <span class="na">total_batch_size</span><span class="pi">:</span> <span class="m">2293760</span>     <span class="c1"># 20 x 2048 x 7 grad_accum x 8 GPUs</span>
  <span class="na">loss</span><span class="pi">:</span> <span class="s">LigerFusedLinearCrossEntropyLoss(softcap=15.0)</span>

<span class="na">optimizer</span><span class="pi">:</span>                      <span class="c1"># composite, one group per parameter class</span>
  <span class="na">matrix</span><span class="pi">:</span>        <span class="s">Muon   lr=0.02      momentum=0.95  wd=0.0</span>
  <span class="na">embeddings</span><span class="pi">:</span>    <span class="s">AdamW  lr=0.1414    betas=(0.8, 0.995)  eps=1e-10  wd=0.001</span>
  <span class="na">lm_head</span><span class="pi">:</span>       <span class="s">AdamW  lr=0.002828  betas=(0.8, 0.96)   eps=1e-10  wd=0.01</span>
  <span class="na">value_embeds</span><span class="pi">:</span>  <span class="s">AdamW  lr=0.0707    betas=(0.8, 0.995)  eps=1e-10  wd=0.01</span>
  <span class="na">scalars</span><span class="pi">:</span>       <span class="s">AdamW  lr=0.005     betas=(0.8, 0.95)   eps=1e-10  wd=0.05</span>

<span class="na">scheduler</span><span class="pi">:</span>
  <span class="na">trapezoidal</span><span class="pi">:</span>
    <span class="na">warmup_ratio</span><span class="pi">:</span> <span class="m">0.05</span>
    <span class="na">warmdown_ratio</span><span class="pi">:</span> <span class="m">0.50</span>
    <span class="na">final_lr_frac</span><span class="pi">:</span> <span class="m">0.05</span>

<span class="na">data</span><span class="pi">:</span>
  <span class="na">dataset</span><span class="pi">:</span> <span class="s">nvidia/Nemotron-ClimbMix  (karpathy/climbmix-400b-shuffle shards)</span>
  <span class="na">tokenizer</span><span class="pi">:</span> <span class="s">gpt2 (tiktoken)</span>
  <span class="na">block_size</span><span class="pi">:</span> <span class="m">2048</span>
  <span class="na">packing</span><span class="pi">:</span> <span class="s">best-fit, BOS-aligned</span>
  <span class="na">batch_size</span><span class="pi">:</span> <span class="s">20 per rank</span>
  <span class="na">num_workers</span><span class="pi">:</span> <span class="m">11</span>

<span class="na">trainer</span><span class="pi">:</span>
  <span class="na">max_steps</span><span class="pi">:</span> <span class="m">32000</span>              <span class="c1"># stopped at ~28,000 -&gt; 65.3B tokens</span>
  <span class="na">eval_every</span><span class="pi">:</span> <span class="m">4000</span>              <span class="c1"># must divide max_steps or the final CORE is skipped</span>
</code></pre></div></div> <p>The AdamW learning rates follow nanochat’s <code class="language-plaintext highlighter-rouge">sqrt(768/d_model)</code> scaling rule; the Muon LR of 0.02 is inherited from there too.</p> <h2 id="appendix-example-text-generation">Appendix: example text generation</h2> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The capital of France is Paris. It is the largest city in France and the second largest city in Europe
The french revolution happened in 1789 and 1799, and was a time of great change in france
At the center of the milky way there is a supermassive black hole. It is called Sagittarius A* (pronounced
Electrons orbit around the nucleus of an atom in a series of energy levels. The energy levels are numbered
Newton discovered the laws of motion and gravity. He also discovered the law of universal gravitation. Newton's
</code></pre></div></div>]]></content><author><name></name></author><category term="AI"/><summary type="html"><![CDATA[A 3.8B model pretrained from scratch to 0.384 CORE on 65B tokens in 43 hours, for $998.]]></summary></entry><entry><title type="html">Understanding XMem Through Synthetic Benchmarks</title><link href="https://hugovergnes.github.io/xmem/" rel="alternate" type="text/html" title="Understanding XMem Through Synthetic Benchmarks"/><published>2026-06-15T00:00:00+00:00</published><updated>2026-06-15T00:00:00+00:00</updated><id>https://hugovergnes.github.io/xmem</id><content type="html" xml:base="https://hugovergnes.github.io/xmem/"><![CDATA[<p><a href="https://github.com/hkchengrex/XMem">XMem</a> is a video object segmentation model built around an explicit memory system.</p> <p>Unlike an object detector, it does not discover the target from a category prompt. It receives an initial segmentation mask that identifies the object of interest, then propagates that object identity through the rest of the video.</p> <p><strong>The underlying idea is broader than segmentation: preserve a compact, searchable representation of earlier observations so that information can be recovered over long sequences</strong>.</p> <p>I wanted to understand what this memory mechanism costs, what the initial mask actually does, and how the model behaves when the target is occluded. I built a small synthetic sequence, ran XMem on my RTX 5090, counted FLOPs across its inference phases, and recorded the IoU before and after occlusion.</p> <p>The main results were:</p> <ul> <li>XMem propagated a first-frame target mask without fine-tuning on the synthetic video.</li> <li>It preserved target identity in the presence of a similar-looking distractor.</li> <li>Its predicted mask disappeared under full occlusion and recovered as the target became visible again. <strong>It didn’t confound the occluded object with the similar looking one still visible</strong>.</li> <li>At 480 short-side resolution, it processed the 180-frame sequence at <code class="language-plaintext highlighter-rouge">~64 FPS</code> using <code class="language-plaintext highlighter-rouge">~940 MiB</code> of peak CUDA allocation with AMP on my 5090.</li> <li>At 720 short-side resolution, throughput fell to <code class="language-plaintext highlighter-rouge">~42 FPS</code> and peak allocation increased to <code class="language-plaintext highlighter-rouge">~2500 MiB</code>.</li> <li>At 480p, mean visible-target IoU was <code class="language-plaintext highlighter-rouge">0.864</code>; the prediction was correctly empty for all six fully occluded frames.</li> <li>The segmentation decoder, not the memory read alone, dominated propagation FLOPs.</li> </ul> <h2 id="the-task-is-mask-propagation-not-object-detection">The task is mask propagation, not object detection</h2> <p>XMem solves semi-supervised video object segmentation. For one object, its initial inputs are conceptually:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RGB frame:    [3, H, W]
object mask:  [1, H, W]
</code></pre></div></div> <p>The mask answers a question that RGB alone cannot answer: which object should the model follow? This matters when a frame contains multiple plausible targets. The model does not need a semantic label such as “car” or “person,” but it does need a seed that establishes object identity.</p> <p>The frame is encoded into spatial features. XMem creates compact keys for matching and richer, mask-conditioned values for object information. At a high level:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>initial frame + mask -&gt; object-specific key/value memory
current frame        -&gt; query keys
memory match         -&gt; retrieved object features
retrieved features   -&gt; current-frame mask
</code></pre></div></div> <p>The synthetic frames are generated at <code class="language-plaintext highlighter-rouge">864x480</code>. Both dimensions are divisible by 16, the stride-16 feature grid is <code class="language-plaintext highlighter-rouge">54x30</code>, or 1,620 positions. With the released checkpoint, a simplified single-object memory entry has shapes similar to:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>key:    [1, 64, 30, 54]
value:  [1, 1, 512, 30, 54]
</code></pre></div></div> <p>The key is shared matching information. The value includes an object dimension because it carries information conditioned by the input mask. This distinction is the core of the model: use a relatively small representation to find relevant memories, then retrieve a larger representation containing target-specific detail.</p> <h2 id="a-synthetic-occlusion-test">A synthetic occlusion test</h2> <p>The synthetic sequence includes three elements:</p> <ul> <li>a masked target moving through the frame;</li> <li>a similar red distractor following a different trajectory;</li> <li>an opaque foreground occluder that fully covers the target during part of the sequence.</li> </ul> <p>Only the first-frame target mask is placed in XMem’s <code class="language-plaintext highlighter-rouge">Annotations</code> input. XMem must propagate that initial identity through all later frames without correction. A separate <code class="language-plaintext highlighter-rouge">EvaluationMasks</code> directory contains the visible target mask for every frame. These evaluation masks subtract the opaque occluder and are never passed to the model. A second evaluation-only mask tracks the visible distractor.</p> <p>The following contact sheet samples the sequence before, during, and after occlusion. Each sample contains two panels. The left panel is the RGB frame with XMem’s prediction overlaid in cyan. The right panel isolates the predicted foreground mask. The reported area is the number of predicted foreground pixels, while the bounding box gives the prediction’s extent in the original frame coordinates.</p> <p><img src="/assets/img/xmem_occlusion_contact_sheet.jpg" alt="XMem mask propagation before, during, and after synthetic occlusion"/></p> <p>This makes the failure and recovery sequence visible rather than reducing it to one aggregate metric:</p> <ul> <li>Before occlusion, XMem followed the seeded target.</li> <li>As the occluder covered the target, the predicted foreground area shrank.</li> <li>Under full occlusion, the prediction became empty.</li> <li>As the target reappeared, XMem first recovered partial masks and then returned to the target.</li> <li>It did not visibly switch identity to the similar distractor in the sampled frames.</li> </ul> <p>This is not evidence that XMem predicts an object’s hidden extent. It did not produce an amodal mask behind the occluder. The result is better understood as visible-region segmentation with identity recovery after reappearance.</p> <p>The per-frame masks turn this into a quantitative test. IoU is computed against the visible target rather than its hidden full extent. Empty-vs-empty IoU is undefined, so the six fully occluded frames are excluded from mean IoU and reported separately. XMem produced an empty prediction on all six. On the remaining frames, the 480p AMP run achieved:</p> <table> <thead> <tr> <th>Slice</th> <th style="text-align: right">Mean IoU</th> </tr> </thead> <tbody> <tr> <td>All frames with defined IoU</td> <td style="text-align: right">0.864</td> </tr> <tr> <td>Before occlusion</td> <td style="text-align: right">0.959</td> </tr> <tr> <td>Partial occlusion</td> <td style="text-align: right">0.752</td> </tr> <tr> <td>After reappearance</td> <td style="text-align: right">0.841</td> </tr> </tbody> </table> <p>The target first reappeared at frame 69 and crossed IoU <code class="language-plaintext highlighter-rouge">0.5</code> at frame 71, a recovery delay of two frames. On average, <code class="language-plaintext highlighter-rouge">0.85%</code> of predicted pixels overlapped the distractor; the maximum on any frame was <code class="language-plaintext highlighter-rouge">12.90%</code>. The partial-occlusion and after-reappearance slices are intentionally not disjoint: the target is still partially covered for some frames after it first reappears.</p> <p><img src="/assets/img/iou_timeline.png" alt="Per-frame visible-target IoU"/></p> <p>The timeline exposes behavior that the aggregate means hide. The shaded interval and dashed boundaries mark the start and end of occlusion, while the orange line marks the target’s reappearance. Missing points correspond to the six empty-ground-truth frames, where IoU is undefined; those frames are covered by the separate correct-empty result rather than being assigned an IoU of 1.</p> <h2 id="inference-cost-depends-on-the-frame-type">Inference cost depends on the frame type</h2> <p>XMem does not perform exactly the same work on every frame. I counted operations for three phases at <code class="language-plaintext highlighter-rouge">864x480</code> with one object:</p> <table> <thead> <tr> <th>Phase</th> <th style="text-align: right">GFLOPs</th> <th style="text-align: right">KMACs/pixel</th> </tr> </thead> <tbody> <tr> <td>First annotated frame</td> <td style="text-align: right">146.90</td> <td style="text-align: right">177.11</td> </tr> <tr> <td>Propagation frame</td> <td style="text-align: right">295.36</td> <td style="text-align: right">356.10</td> </tr> <tr> <td>Propagation with memory update</td> <td style="text-align: right">381.61</td> <td style="text-align: right">460.09</td> </tr> </tbody> </table> <p>The first annotated frame initializes object memory and is the least expensive of these phases. A normal propagation frame reads memory and decodes a segmentation. A memory-update frame encodes a new value to write back into memory, adding about <code class="language-plaintext highlighter-rouge">86.25 GFLOPs</code>.</p> <p>The <code class="language-plaintext highlighter-rouge">ValueEncoder</code> accounted for about <code class="language-plaintext highlighter-rouge">88.86 GFLOPs</code> in the module table, which closely matches the measured increase. This makes the tradeoff behind XMem’s memory-update interval concrete: writing more often gives the model fresher object information, but value encoding is not free.</p> <h2 id="the-decoder-dominates-propagation-compute">The decoder dominates propagation compute</h2> <p>I initially expected content-addressed memory matching to dominate inference. At this resolution, it did not. The propagation breakdown was:</p> <table> <thead> <tr> <th>Module</th> <th style="text-align: right">GFLOPs</th> <th style="text-align: right">Share</th> </tr> </thead> <tbody> <tr> <td>Decoder</td> <td style="text-align: right">234.00</td> <td style="text-align: right">79%</td> </tr> <tr> <td>Key encoder</td> <td style="text-align: right">54.19</td> <td style="text-align: right">18%</td> </tr> <tr> <td>Key projection</td> <td style="text-align: right">3.82</td> <td style="text-align: right">1%</td> </tr> <tr> <td>Reported memory <code class="language-plaintext highlighter-rouge">bmm</code> operations</td> <td style="text-align: right">3.36</td> <td style="text-align: right">1%</td> </tr> </tbody> </table> <p>Most decoder cost came from higher-resolution fusion and upsampling:</p> <table> <thead> <tr> <th>Decoder component</th> <th style="text-align: right">GFLOPs</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">up_8_4</code></td> <td style="text-align: right">91.73</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">fuser</code></td> <td style="text-align: right">70.71</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">up_16_8</code></td> <td style="text-align: right">68.80</td> </tr> </tbody> </table> <p>This changes how I would reuse XMem’s ideas in a smaller system. The key/value memory pattern is attractive, but copying the full segmentation decoder and mask-conditioned value encoder would bring most of the compute with it.</p> <h2 id="resolution-is-the-practical-constraint">Resolution is the practical constraint</h2> <p>The wall-clock benchmark used the same 180-frame synthetic sequence:</p> <table> <thead> <tr> <th>Configuration</th> <th style="text-align: right">Time</th> <th style="text-align: right">FPS</th> <th style="text-align: right">Peak CUDA allocation</th> </tr> </thead> <tbody> <tr> <td>480 short side, AMP</td> <td style="text-align: right">2.82 s</td> <td style="text-align: right">63.87</td> <td style="text-align: right">938.7 MiB</td> </tr> <tr> <td>480 short side, FP32</td> <td style="text-align: right">2.41 s</td> <td style="text-align: right">74.59</td> <td style="text-align: right">1057.3 MiB</td> </tr> <tr> <td>720 short side, AMP</td> <td style="text-align: right">4.32 s</td> <td style="text-align: right">41.63</td> <td style="text-align: right">2493.8 MiB</td> </tr> </tbody> </table> <p>Moving from a 480 to 720 short side increases pixel count by <code class="language-plaintext highlighter-rouge">2.25x</code>. Throughput decreased by about <code class="language-plaintext highlighter-rouge">36%</code>, while measured peak allocation increased by <code class="language-plaintext highlighter-rouge">2.65x</code>.</p> <p>The memory increase is not only caused by larger CNN feature maps. XMem compares current-frame query positions against stored memory positions. In this implementation, the dense similarity tensor is constructed before top-k selection. Increasing resolution therefore expands both sides of the matching operation: there are more locations in each stored frame and more query locations in the current frame.</p> <p>An experiment above 720 short side made the workstation unresponsive, so I removed that case from the benchmark. This is not a universal resolution limit for XMem. It is a practical limit for this sequence, configuration, and workstation. Memory cadence, working-memory size, video length, object count, and software versions can all change the outcome.</p> <p>One other result was counterintuitive: FP32 was faster than AMP at 480p, although it used more memory. On this run, FP32 delivered <code class="language-plaintext highlighter-rouge">74.59 FPS</code> versus <code class="language-plaintext highlighter-rouge">63.87 FPS</code> with AMP. Autocast overhead, kernel selection, TF32 behavior, and run-to-run variance could all contribute. I would not generalize from one run; repeated warmed-up measurements are needed before concluding that FP32 is consistently faster.</p> <h2 id="closing-thoughts">Closing thoughts</h2> <p>XMem is a useful example of explicit memory applied to video. Its first-frame mask creates object-specific memory, its compact keys support content-based lookup, and its values preserve richer information for mask decoding. On my synthetic test, this was enough to maintain identity through an occlusion and reappearance event without fine-tuning on the sequence.</p> <p>The implementation is not lightweight. At 480p, a normal propagation frame required about <code class="language-plaintext highlighter-rouge">295 GFLOPs</code>, and the decoder accounted for most of that cost. Memory-update frames were more expensive because value encoding added roughly <code class="language-plaintext highlighter-rouge">86 GFLOPs</code>. Increasing resolution also produced a sharp memory increase because spatial memory matching grows with both stored and current-frame positions.</p> <p>XMem’s most reusable idea is not necessarily its complete architecture. It is the separation between a small key used to find relevant history and a larger, object-conditioned value used to reconstruct detail. That pattern is applicable beyond segmentation, provided the memory lookup and decoder are designed around the resolution and hardware constraints of the final system.</p> <p>fork: https://github.com/hugovergnes/XMem/tree/experiment/synthetic-occlusion-benchmark</p>]]></content><author><name></name></author><category term="AI"/><summary type="html"><![CDATA[XMem is a video object segmentation model built around an explicit memory system.]]></summary></entry><entry><title type="html">State-of-the-Art in Computer Vision: ViT, CNNs and Beyond</title><link href="https://hugovergnes.github.io/beyond/" rel="alternate" type="text/html" title="State-of-the-Art in Computer Vision: ViT, CNNs and Beyond"/><published>2024-12-10T00:00:00+00:00</published><updated>2024-12-10T00:00:00+00:00</updated><id>https://hugovergnes.github.io/beyond</id><content type="html" xml:base="https://hugovergnes.github.io/beyond/"><![CDATA[<p>Since I graduated in 2022, the landscape of computer vision has been transformed by the rise of vision transformers (ViTs) and their hierarchical counterparts, which have challenged the dominance of convolutional neural networks (CNNs). CNNs, championed by architectures like ConvNeXt, remain highly competitive due to their inherent regularization properties and strong inductive biases. These biases, such as locality and translation equivariance, enable CNNs to efficiently model image data while requiring fewer training examples to generalize well. In the medical field, CNNs are still ubiquitous. The aforementioned biases match the problem quite well: finding the needle (cancer) in a haystack (an X-ray image).</p> <p>But the same inductive biases inherently limit their flexibility when compared to vision transformers. ViTs, by treating images as sequences of patches, discard these biases in favor of learning global relationships directly from data. This flexibility has enabled vision transformers to excel in scenarios where vast amounts of labeled or pre-training data are available, leveraging their capacity to model long-range dependencies across the image. However, this does not work as well in medical imaging, where the available data is more limited and often has a very high resolution.</p> <p>In this post, I’ll discuss my experience working with all those networks. How modern CNNs like ConvNext have evolved to remain relevant, how vision transformers such as Swin-V2 and PvT-V2 integrate hierarchical design principles to balance efficiency and expressivity, and how models like Hiera push the boundaries of self-supervised learning.</p> <div> <h2 style="margin: 0;">2. The inductive biases at the core of CNNs.</h2> </div> <div> <h3 style="margin: 0;">2.1 Locality </h3> </div> <p>Locality assumes meaningful patterns or features can be found within small, localized regions of the input data. This concept is fundamental to how convolutional layers operate and why CNNs are so effective for spatially structured data like images. In practice, this is found in the size of the convolution kernels (3<em>3 or 5</em>5). As the filter slides through the image, only the pixels in the receptive field are considered.</p> <p>In breast cancer, tumors often have a spiculated appearance. This star-shaped structure can be detected with this type of operation.</p> <div> <h3 style="margin: 0;">2.2 Translation Equivariance </h3> </div> <p>Translation equivariance means that when an input is translated (shifted) spatially, the feature map produced by the convolutional layer shifts by the same amount, preserving the spatial relationships. In other words the two operations commute:</p> \[\text{For any translation operator } T \text{ and convolution } conv, \\ conv(T(x)) = T(conv(x))\] <p>This is useful in the medical field. When you want to find a tumor in a large image (like a medical scan), the precise location of the tumor does not matter that much. If it’s translated to the right or left, it should produce the same feature map.</p> <div> <h3 style="margin: 0;">2.3 Spatial Hierarchy</h3> </div> <p>This assumes that complex patterns can be built hierarchically from simpler patterns (e.g., edges → textures → objects). In a way, locality is the fine-grained detection of edges in the image, and spatial hierarchy allows the reconstruction of more complex structures. In ImageNet, you would typically detect edges, then a dog’s ear, and then a dog. This was initially how it was presented with AlexNet.</p> <div> <h3 style="margin: 0;">2.4 Parameter Sharing</h3> </div> <p>This assumes that the same feature can occur across different parts of the input, so the same set of weights (filters) can be used everywhere.</p> <p>Once again, in our cancer detection example, this is adequate. Cancer can appear in different parts of scans; its low-level features (edges, etc.) should be detected the same way.</p> <div><h2 style="margin: 0;">3. ViT are generally more flexible but more computationally expensive</h2></div> <p>Transformers were initially proposed as an NLP algorithm, where each word was treated as a token. In computer vision, you need to divide images into patches and treat those patches as tokens. Unlike CNNs, Vision Transformers do not rely on locality as an inductive bias. Instead, ViTs treat the input image as a sequence of patches, much like words in a sentence for natural language processing. This approach allows ViTs to learn global relationships between image regions from the start, without assuming that important features are localized.</p> <p>This is both a curse and a blessing for Transformers. It’s a curse because the attention operation scales quadratically with the size of the image or as you reduce the size of the patches. An alternative is to use bigger patches for bigger images, but that limits the effective resolution of the patches processed in the transformer. This usually leads to poor performance. But this can also be a blessing because, if you can afford it, you can relax the inductive biases and generally get better performance.</p> <div><h2 style="margin: 0;">4. Modernizing CNNs: ConvNext, NFNets</h2></div> <p>ConvNext reimagines traditional CNNs with a modern twist, incorporating design elements inspired by vision transformers, such as large kernel sizes, depthwise convolutions, and layer normalization. While retaining the strong inductive biases of CNNs, ConvNext reduces these limitations by improving flexibility and scalability. This makes it highly competitive on benchmarks, offering a well-regularized alternative to transformers, especially in data-limited settings.</p> <p>NFNets (Normalizer-Free Networks) are convolutional neural networks introduced by Google Research in 2021, and they share similarities with modern CNNs like ConvNeXt in their architectural improvements. Most notably, they remove the use of Batch Normalization (which has several problems of its own). They also incorporate several design features found in ConvNeXt, such as depthwise separable convolutions for efficiency. This operation splits the standard convolution into two steps. First, a depthwise convolution applies a single filter per input channel rather than aggregating across all channels. Second, a pointwise convolution aggregates depthwise information across all channels. It also proposes better initialization, optimization methods, etc.</p> <p>Overall, scaling those models results in performance as good as that of Vision Transformers.</p> <div><h2 style="margin: 0;">5. Modernizing Vision transformers</h2></div> <p>Similarly, Vision Transformers have undergone considerable modernization in the last couple of years.</p> <p>Most noteworthy, and distilled in many variants, is the addition of a hierarchical structure to the network. This drastically reduces the computational cost of those networks. Swin-V2 introduces shifted windows, which enable hierarchical feature learning while maintaining global context through cross-window connections. This mechanism avoids redundant computations by focusing on non-overlapping regions of the input, significantly reducing complexity compared to traditional attention mechanisms.</p> <p>On the other hand, PvT-V2 achieves a hierarchical structure by progressively reducing spatial dimensions through patch-merging layers, allowing the model to capture multi-scale features efficiently. These hierarchical designs not only improve scalability but also enhance performance on tasks requiring both fine-grained details and global understanding, making them highly effective for large-scale vision applications. In my work, I found PvT-V2 to be an outstanding model, offering high accuracy with an unusually small number of parameters and low computational cost. For example, at a similar performance level, PvT-V2 would take half the memory of ConvNeXt and train twice as fast.</p> <p>Let’s mention something that is becoming ubiquitous in Transformers: Flash Attention. This is now a default setting in PyTorch if the hardware can support it. Flash Attention is an optimized attention mechanism designed to reduce memory usage and computational overhead, particularly for large models. Unlike traditional attention mechanisms, which have quadratic memory complexity with respect to the sequence length, Flash Attention leverages efficient memory access patterns and kernel optimizations to compute attention in a memory-efficient way. By implementing a more compact representation of the attention matrix and using hardware acceleration techniques, Flash Attention can handle longer sequences with significantly lower memory consumption and faster processing speeds, making it ideal for tasks with large-scale inputs, such as transformers applied to vision and language models. This makes it a promising technique for scaling up models without compromising performance.</p> <div><h2 style="margin: 0;">More takeaways and insights</h2></div> <ul> <li>A lot of discussion revolves around how transformers work. There is an overall impression (which I share) that transformers learn more texture-based than shape-based features when compared with CNNs. This is due to the inductive biases we just mentioned.</li> <li>ViTs excel in tasks where global context or relationships are crucial, especially when sufficient data and computational resources are available. CNNs are more efficient and effective when local patterns dominate the task (e.g., detecting edges or textures) or when computational resources are limited.</li> </ul> <div><h2 style="margin: 0;"> Future Directions</h2></div> <p>With ChatGPT, SAM2, and more AI-powered solutions, I’ve noticed a shift toward making these models more readily available to end users. This is a welcome change, but it comes with challenges in making the demo compelling. This takes more engineering effort than research effort. I predict that this trend will consolidate and that we will see more applications that leverage the low latency of modern hardware.</p> <p>I also suspect that self-supervision will become more prevalent, especially for tackling scenarios where data is not available. In the medical field, this could involve a new scanner type. If you have trained an AI algorithm, you would like it to also work well on newer, more sophisticated versions of the scanner. But of course, since it is newer, you don’t have as much training data available. Pre-training can help level that field.</p> <p>Finally, as much as I would like to see a change in paradigm, it looks like a moonshot. I think the sequence-to-sequence approach in ViT is ill-suited for vision, but it clearly yields remarkable performance.</p>]]></content><author><name></name></author><category term="algorithms"/><summary type="html"><![CDATA[Since I graduated in 2022, the landscape of computer vision has been transformed by the rise of vision transformers (ViTs) and their hierarchical counterparts, which have challenged the dominance of convolutional neural networks (CNNs). CNNs, championed by architectures like ConvNeXt, remain highly competitive due to their inherent regularization properties and strong inductive biases. These biases, such as locality and translation equivariance, enable CNNs to efficiently model image data while requiring fewer training examples to generalize well. In the medical field, CNNs are still ubiquitous. The aforementioned biases match the problem quite well: finding the needle (cancer) in a haystack (an X-ray image).]]></summary></entry><entry><title type="html">A comparative study of AI and expert radiologist performance for technical recall assessment in screening mammography</title><link href="https://hugovergnes.github.io/rsna/" rel="alternate" type="text/html" title="A comparative study of AI and expert radiologist performance for technical recall assessment in screening mammography"/><published>2024-09-21T00:00:00+00:00</published><updated>2024-09-21T00:00:00+00:00</updated><id>https://hugovergnes.github.io/rsna</id><content type="html" xml:base="https://hugovergnes.github.io/rsna/"><![CDATA[<p>In 2024, my work was accepted for scientific presentation at RSNA 2024!</p> <figure> <img src="/assets/img/Figure_1_IQA.png" alt="Description of the image"/> <figcaption>Figure 1 A) A cross-validation approach is employed to compare AI and reader performance for assessing image quality. Each reader is evaluated against a reference standard determined by the other readers. The AI uses end-to-end and MQSA-based features to evaluate IQ. Readers and AI are systematically evaluated against the same ground truth. B) Pairwise agreement between the readers and the AI, measured by Cohen’s kappa. It shows a low level of agreement due to the task’s subjectivity but most readers (3 out of 5) agreed more closely with the AI than with other readers. The maximum value is bolded for each reader. C) The average sensitivity and specificity for the readers and the AI in the cross-validation approach. The maximum value is bolded for each metric. D) The performance of the AI when all the radiologists are used in the consensus.</figcaption> </figure> <h2 id="purpose">Purpose</h2> <p>Safeguards are needed to protect autonomous artificial intelligence (AI) rule-out systems for breast cancer screening from exams with image quality (IQ) deficiencies that could impair the sensitivity of the models. This work introduces an AI system that assesses IQ in full-field digital mammography (FFDM) exams and evaluates it against expert radiologists.</p> <h2 id="materials-and-methods">Materials and Methods</h2> <p>An AI system was trained to identify screening FFDM exams that require a technical recall due to inadequate IQ. The system evaluates positioning and blur, with features based on MQSA criteria as well as learned features. This model was evaluated on 1,100 screening exams from 2 independent held-out U.S. datasets (Dataset 1: 835 exams, from 2012-2019; Dataset 2: 265 exams, from 2002-2009) that were labeled as requiring recall or not by 5 breast fellowship-trained radiologists with at least 15 years of experience (used for testing only).</p> <p>The performance of the model was first evaluated based on a reference standard of the median assessment of the 5 readers. To compare the AI and individual readers, we employ an evaluation methodology similar to leave-one-out cross-validation. In this method, we left out one reader at a time to establish a consensus based on the assessments of the other readers. Then, we compared the excluded reader and the AI model to this consensus. We repeated this for each reader to ensure an unbiased comparison between the AI and readers.</p> <h2 id="results">Results</h2> <p>The percentage of exams recalled (inadequate IQ) by the 5 readers ranged from 0.7% to 13.7% (Mean: 6.4% Standard Deviation: 4.8%). Pairwise agreement between the readers was low (Cohen’s kappa: 0.06-0.36). Based on a reference standard established by all 5 readers, the AI model achieved a sensitivity of 66.7% [48.2%, 82.0%], a specificity of 96.3% [94.9%, 97.3%], and an area under the receiver operating characteristic curve (AUC) of 0.92 [0.88, 0.96]. The sensitivity of the model increased when more readers labeled a sample as inadequate (3/5 readers: 56.5%, 13/23; 4/5 readers: 83.3%, 5/6; 5/5 readers: 100.0%, 4/4). Based on the cross-validation approach, the system exhibited comparable performance to the mean of the readers in sensitivity (AI: 48.9% [45.5%, 52.3%]; Mean Rad.: 45.7% [18.3%, 73.1%]) and specificity (AI: 96.1% [95.6%, 96.5%]; Mean Rad.: 95.0% [89.5%, 100.0%]). The system achieved an average AUC of 0.86 [0.85, 0.87].</p> <h2 id="conclusions">Conclusions</h2> <p>This study demonstrates that an AI model can achieve comparable performance to expert radiologists in assessing IQ for mammography, overcoming the inherent challenges in the task’s subjectivity.</p> <h2 id="clinical-relevanceapplication">Clinical Relevance/Application</h2> <p>AI systems that provide an automatic check on IQ can assist with improving image acquisition and also help safeguard cancer-detecting AI models from exams with poor IQ.</p>]]></content><author><name></name></author><category term="algorithms"/><summary type="html"><![CDATA[In 2024, my work was accepted for scientific presentation at RSNA 2024!]]></summary></entry><entry><title type="html">Hierarchical Vision Transformers as Masked Autoencoders</title><link href="https://hugovergnes.github.io/Hiera/" rel="alternate" type="text/html" title="Hierarchical Vision Transformers as Masked Autoencoders"/><published>2024-09-19T00:00:00+00:00</published><updated>2024-09-19T00:00:00+00:00</updated><id>https://hugovergnes.github.io/Hiera</id><content type="html" xml:base="https://hugovergnes.github.io/Hiera/"><![CDATA[<p>In recent years, Vision Transformers (<a href="https://arxiv.org/abs/2010.11929">ViT</a>) have garnered significant attention for their impressive performance in computer vision tasks. It challenged the traditional vision paradigm that relies on convolutions. The sequence-to-sequence paradigm is less intuitive for vision tasks compared to NLPs, but some architectures have proven to be undeniably strong at many vision tasks.</p> <h2 id="the-challenges-of-vit-with-high-resolution-images">The Challenges of ViT with high-resolution images</h2> <p>As the resolution of images increases, the computational and memory demands of ViTs become increasingly prohibitive. Because the number of tokens grows quadratically with the image resolution! This quickly becomes prohibitive. This is where Hierarchical Vision Transformers (<a href="https://arxiv.org/abs/2306.00989">Hiera</a>) shine as a more efficient and scalable alternative.</p> <p>Vision Transformers rely on dividing an image into fixed-size non-overlapping patches, which are then treated as tokens for the model. The key issue arises from the fact that each token is processed through all stages of the transformer. As a result, when working with high-resolution images, the number of tokens increases dramatically, leading to a massive increase in computational complexity and memory usage. This makes ViT less feasible for large images, where processing all tokens at every stage becomes impractical.</p> <h2 id="how-hiera-addresses-these-issues">How Hiera addresses these issues</h2> <p>Hiera, on the other hand, takes a hierarchical approach to token processing. Pooling the query tokens (Q) significantly improves efficiency. Unlike ViT, Hiera does not process all tokens at every stage. Instead, it selectively reduces the number of tokens passed through deeper layers. This hierarchical tokenization ensures that only the most relevant tokens are processed at higher levels, drastically reducing the memory footprint while maintaining or even improving model performance.</p> <p>In high-resolution image tasks, this approach allows Hiera to maintain a more manageable number of tokens at each stage. By focusing on the most important information at each level, Hiera can capture fine-grained details without the need to process the entire set of tokens throughout the model. This makes Hiera a strong candidate for replacing ViT, especially when working with images of very high resolution.</p> <h2 id="further-development-for-higher-resolution-images">Further development for higher resolution images</h2> <p>However, many groups, including mine at work, witnessed something unexpected. Hiera really doesn’t generalize well to different resolutions. This shouldn’t happen: the only component of a Vision Transformer that requires knowledge of the input resolution is the positional embedding. There is no reason for this to cause any problems for the higher-resolution downstream task. It turns out the problem arose from an interaction between <a href="https://arxiv.org/abs/2311.05613">Window Attention and the positional embedding</a>. Hiera was then used in the trunk of the image encoder for <a href="https://github.com/facebookresearch/segment-anything-2">SAM2</a>. It was one of the key contributions that enabled the creation of this compelling real-time demo.</p> <p>This was an exciting innovation to see, as it led to great improvements. In a field where we need more players, we need more papers that make it possible for smaller groups to propose SOTA systems with fewer resources.</p> <h2 id="in-my-experience-pre-training-training-and-inference">In my experience. Pre-training, training and inference.</h2> <p>Hiera holds much promise as it addresses the prohibitive memory usage of non-hierarchical Vision Transformers. However, they still rely on a sequence-to-sequence paradigm that is not ideal for vision. In my experience, it appears that the inductive biases inherent to CNNs make for better models. Typically, ConvNeXt is a very hard model to beat in downstream prediction tasks.</p> <p>In my experience, Hiera undeniably beats ViT. But it is still underwhelming in many aspects. First, the MAE pre-training step is computationally expensive. This is a heavy constraint on relatively GPU-poor groups. Second, the performance of the fine-tuning step is lackluster. Not only does it still require a decent amount of data for the prediction task, but the model also struggles to compete with its more advanced counterparts (ConvNeXt, PvT, MViT, Swin-V2) that are initialized from ImageNet and trained directly on the task (with no specific pre-training task). I haven’t managed to make it really competitive despite my best efforts and extensive experimentation (different masking ratios, layer-wise learning rate decay, high batch sizes, and many different fine-tuning settings). It gets better if you keep the earlier layers unchanged (prefer downsampling the image over cropping, keep the patch size fixed between pre-training and fine-tuning, etc.), but it never becomes SOTA on the proprietary datasets I worked on. Third, it looks like Hiera has some convergence issues. The pre-training step might result in occasional crashes where the loss suddenly becomes NaN and all the weights are messed up. All of this is for a speed increase in the forward and backward passes that is marginal for relatively high-resolution images (~3000 × 2000).</p> <h2 id="closing-thoughts">Closing thoughts</h2> <p>I think Hiera and MAE in general are interesting approaches. But they offer little utility when you try to apply them without some of the biggest datasets out there, coupled with serious GPU power. Nonetheless, I think pretext tasks for pre-training make sense and might help in many downstream scenarios. I’m excited to read more research on this topic.</p>]]></content><author><name></name></author><category term="AI"/><summary type="html"><![CDATA[In recent years, Vision Transformers (ViT) have garnered significant attention for their impressive performance in computer vision tasks. It challenged the traditional vision paradigm that relies on convolutions. The sequence-to-sequence paradigm is less intuitive for vision tasks compared to NLPs, but some architectures have proven to be undeniably strong at many vision tasks.]]></summary></entry><entry><title type="html">A heuristic algorithm to solve Sudoku puzzles</title><link href="https://hugovergnes.github.io/sudokus/" rel="alternate" type="text/html" title="A heuristic algorithm to solve Sudoku puzzles"/><published>2024-08-02T00:00:00+00:00</published><updated>2024-08-02T00:00:00+00:00</updated><id>https://hugovergnes.github.io/sudokus</id><content type="html" xml:base="https://hugovergnes.github.io/sudokus/"><![CDATA[<p>This project is an exploration of solving Sudoku puzzles using a heuristic approach that mimics human strategies. A common computer science principle used to solve Sudoku is backtracking. When confronted with two options, pick one and see if it could result in a solution; if not, backtrack to where you made the split. For a human, this is really annoying because if that branch “fails”—i.e., you arrive at a contradiction—you have to revert to the point where you branched out. I like to solve my Sudoku puzzles in one shot.</p> <p>In this repository, we approach it in a simpler manner where we solve each sudoku as a human would, by putting a digit down only if no other digits can go in this cell or if that digit can only go here.</p> <p>The code involves representing the Sudoku board as a Table class, where each individual cell is an instance of the Cell class. I did this project as a way to make a long flight interesting, so the goal was to have fun with a puzzle that has fascinated me since I was a kid while applying and practicing principles of good software engineering.</p> <p>The whole code revolves around a Cell class representing a cell in the Sudoku board. Once you have this, you just need to keep track of candidate values, and it solves itself!</p> <figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="k">class</span> <span class="nc">Cell</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">pos</span><span class="p">,</span> <span class="n">value</span><span class="o">=</span><span class="mi">0</span><span class="p">):</span>
        <span class="sh">"""</span><span class="s">_summary_

        Args:
            pos (Tuple): Position of the Cell in the sudoku table.
            value (int, optional): Value in the cell. Defaults to 0 if the cell is unknown.
            candidate_values (set): Possible values in that cell. If the cell is already set to
            non-0. Then It is empty.

        Raises:
            RuntimeError: _description_
        </span><span class="sh">"""</span>
        <span class="n">self</span><span class="p">.</span><span class="n">pos</span> <span class="o">=</span> <span class="n">pos</span>
        <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">candidate_values</span> <span class="o">=</span> <span class="nf">set</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">__str__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="nf">str</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">value</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">__eq__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">other</span><span class="p">):</span>
        <span class="k">if</span> <span class="nf">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="nb">int</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="o">==</span> <span class="n">other</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="o">==</span> <span class="n">other</span><span class="p">.</span><span class="n">value</span> <span class="ow">and</span> <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="o">==</span> <span class="n">other</span><span class="p">.</span><span class="n">value</span>

    <span class="k">def</span> <span class="nf">is_known</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span>

    <span class="k">def</span> <span class="nf">get_value</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">value</span>

    <span class="k">def</span> <span class="nf">set_value</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">value</span>
        <span class="n">self</span><span class="p">.</span><span class="n">candidate_values</span> <span class="o">=</span> <span class="nf">set</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">get_candidate_values</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">candidate_values</span>

    <span class="k">def</span> <span class="nf">get_prohibited_values</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
        <span class="n">prohibited_values</span> <span class="o">=</span> <span class="nf">sorted</span><span class="p">(</span>
            <span class="nf">set</span><span class="p">([</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">10</span><span class="p">)])</span> <span class="o">-</span> <span class="n">self</span><span class="p">.</span><span class="n">candidate_values</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">prohibited_values</span></code></pre></figure> <p>If you liked this post, you should check out <a href="https://github.com/hugovergnes/sudoku/tree/main">my repo.</a></p>]]></content><author><name></name></author><category term="algorithms"/><summary type="html"><![CDATA[This project is an exploration of solving Sudoku puzzles using a heuristic approach that mimics human strategies. A common computer science principle used to solve Sudoku is backtracking. When confronted with two options, pick one and see if it could result in a solution; if not, backtrack to where you made the split. For a human, this is really annoying because if that branch “fails”—i.e., you arrive at a contradiction—you have to revert to the point where you branched out. I like to solve my Sudoku puzzles in one shot.]]></summary></entry><entry><title type="html">Ultimate Fighting Championship in a graph</title><link href="https://hugovergnes.github.io/UFC-Dataset-Exploration/" rel="alternate" type="text/html" title="Ultimate Fighting Championship in a graph"/><published>2023-01-01T00:00:00+00:00</published><updated>2023-01-01T00:00:00+00:00</updated><id>https://hugovergnes.github.io/UFC-Dataset-Exploration</id><content type="html" xml:base="https://hugovergnes.github.io/UFC-Dataset-Exploration/"><![CDATA[<figure> <img src="/assets/img/ufc_graph.png" alt="Description of the image"/> <figcaption>Who fought who in UFC lightweight and welterweight match-ups as of 2023.</figcaption> </figure>]]></content><author><name></name></author><category term="curiosities"/><summary type="html"><![CDATA[Who fought who in UFC lightweight and welterweight match-ups as of 2023.]]></summary></entry><entry><title type="html">Path Integral Based Convolution Graph Neural Network to solve the molhiv dataset</title><link href="https://hugovergnes.github.io/GCN-for-drugs/" rel="alternate" type="text/html" title="Path Integral Based Convolution Graph Neural Network to solve the molhiv dataset"/><published>2021-03-19T00:00:00+00:00</published><updated>2021-03-19T00:00:00+00:00</updated><id>https://hugovergnes.github.io/GCN-for-drugs</id><content type="html" xml:base="https://hugovergnes.github.io/GCN-for-drugs/"><![CDATA[<p>Graph Neural Networks (GNNs) have shown significant promise in tackling molecular data problems, especially for tasks like drug discovery. In my latest project, I implemented a Path Integral-Based Convolutional Kernel to solve the molhiv dataset on the Open Graph Benchmark (OGB). This approach borrows concepts from physics, particularly path integrals, to improve the way GNNs aggregate information across molecular graphs, offering an innovative method to process edge and node relationships.</p> <p>The implementation was designed to be lightweight and adaptable. While I initially ran the training on a CPU, the model can easily be transferred to a GPU using Google Colab for faster results. If you’re interested in experimenting with it, I’ve also included a ready-to-go Colab notebook that automates the setup. Special thanks to the authors of the original paper on Path Integral-based GNNs and the guidance from CS224W.</p>]]></content><author><name></name></author><category term="AI"/><summary type="html"><![CDATA[Graph Neural Networks (GNNs) have shown significant promise in tackling molecular data problems, especially for tasks like drug discovery. In my latest project, I implemented a Path Integral-Based Convolutional Kernel to solve the molhiv dataset on the Open Graph Benchmark (OGB). This approach borrows concepts from physics, particularly path integrals, to improve the way GNNs aggregate information across molecular graphs, offering an innovative method to process edge and node relationships.]]></summary></entry></feed>