<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Enes K. Ergin</title>
    <link rel="self" type="application/atom+xml" href="https://eneskemalergin.github.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-06-02T00:00:00+00:00</updated>
    <id>https://eneskemalergin.github.io/atom.xml</id>
    <entry xml:lang="en">
        <title>It&#x27;s Alive!</title>
        <published>2026-06-02T00:00:00+00:00</published>
        <updated>2026-06-02T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-its-alive/"/>
        <id>https://eneskemalergin.github.io/blog/post-its-alive/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-its-alive/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; This site is now built with &lt;a href=&quot;https:&#x2F;&#x2F;www.getzola.org&quot; target=&quot;_blank&quot;&gt;Zola&lt;&#x2F;a&gt;, a static site generator that turns markdown into HTML in under 100ms. Custom shortcodes for callouts, sidenotes, code blocks, figures, and collapsible details. Old posts migrated from an obsolete platform with retroactive commentary added through sidenotes. Dark mode included. No JavaScript framework required.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-branch-that-would-not-die&quot;&gt;The branch that would not die&lt;&#x2F;h2&gt;
&lt;p&gt;For months, this site existed as a branch on my machine. I would open it on weekends. Tweak the CSS. Write a shortcode. Close it again. The branch grew opinions about font weights.&lt;&#x2F;p&gt;
&lt;p&gt;Today I merged it to main.&lt;&#x2F;p&gt;
&lt;p&gt;The old site was on a platform I do not want to name because it is not their fault I outgrew it
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;That part bothered me more than the performance. Writing a post should mean opening a file, writing, and running a command. Not fighting a WYSIWYG editor that keeps rearranging your paragraph breaks.
&lt;&#x2F;span&gt;
. It worked. It was also slow, locked into a database, and required logging into a browser to publish a 200-word note.&lt;&#x2F;p&gt;
&lt;p&gt;I needed something I could edit in vim and deploy with a single command. Zola does both. Markdown files in, static HTML out. No database. No login screen. No build step that outlasts my attention span.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;.&#x2F;build.sh    # pre-process tags, generate star colors, build site
git push      # GitHub Pages picks it up automatically&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: The entire deployment pipeline.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;That is the whole thing. Three files to publish a post, one push to deploy it. I spent months building what amounts to a very opinionated markdown renderer with nice CSS.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;shortcodes-that-do-one-thing&quot;&gt;Shortcodes that do one thing&lt;&#x2F;h2&gt;
&lt;p&gt;Zola calls them shortcodes. They are template fragments you invoke from markdown
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Block shortcodes use the bracket-percent syntax and wrap content. Inline shortcodes use the double-brace syntax. Both are Zola-specific. Other static site generators handle this differently.
&lt;&#x2F;span&gt;
. A file in &lt;code&gt;templates&#x2F;shortcodes&#x2F;&lt;&#x2F;code&gt;, a parameter or two, and suddenly you have Notion-style formatting in a static site.&lt;&#x2F;p&gt;
&lt;p&gt;I built callouts, sidenotes, code blocks, figures, and collapsible details. Each one because I kept wanting that thing and it did not exist. The callout gives me colored boxes with emoji icons for tip, note, warning, and danger. The sidenote floats commentary into the right margin so it does not break the reading flow. The code block adds a language badge so you know what you are looking at. Small things. They add up.&lt;&#x2F;p&gt;
&lt;p&gt;The post kind system came from the same place. I wanted the blog listing to tell you what you were clicking into before you clicked. Technical deep dives get a table of contents and reading time. Short observations get neither. Opinion pieces and paper notes get their own presentation. The badge in the header and on the listing page handles the rest.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;design-that-stays-out-of-the-way&quot;&gt;Design that stays out of the way&lt;&#x2F;h2&gt;
&lt;p&gt;The design brief was short: make it look like I wrote it. Not like a template got filled in.&lt;&#x2F;p&gt;
&lt;p&gt;Muted backgrounds. Inter at weight 300 for body text. A teal accent that appears exactly where you need it and nowhere else. Monospace for code, metadata, and dates. Dark mode that is not an afterthought
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;There is something absurd about spending this much time on CSS for a personal blog. And also something satisfying about getting the teal accent exactly right in both light and dark mode.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;The site fits a lot of information into a small space without feeling crowded. That balance took the longest to get right.&lt;&#x2F;p&gt;
&lt;p&gt;The build produces 20 static pages and completes in 92ms. Total page weight, including fonts and CSS, is about the same as a single Medium article loaded with their JavaScript framework, analytics tracker, and cookie consent banner
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;I checked. The difference is two orders of magnitude. This is not a flex. It is an observation about what we accepted as normal.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-old-posts-live-here-now&quot;&gt;The old posts live here now&lt;&#x2F;h2&gt;
&lt;p&gt;I migrated most of the old content. Some posts did not make the cut. Unfinished drafts, notes that no longer applied, a few early pieces that did not fit what I want this site to be. The ones that stayed are organized by kind and retroactively tagged to cross-link with current projects.&lt;&#x2F;p&gt;
&lt;p&gt;The oldest surviving post is from 2018, a fuzzy C-means clustering write-up from my Master&#x27;s
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;I re-read it before migrating and added a note where my understanding of the math had changed. Young me was enthusiastic if not always precise.
&lt;&#x2F;span&gt;
. The content is untouched. The commentary is new.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;Posts that made the journey&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;ul&gt;
&lt;li&gt;Fuzzy C-Means clustering on the Iris dataset (2018)&lt;&#x2F;li&gt;
&lt;li&gt;Prosit spectral prediction (2019)&lt;&#x2F;li&gt;
&lt;li&gt;PRODA: proteoform detection (2020)&lt;&#x2F;li&gt;
&lt;li&gt;ProteoForge deep dive (2025)&lt;&#x2F;li&gt;
&lt;li&gt;Opinion pieces on Zig, vendor formats, search engines, storage (2025-2026)&lt;&#x2F;li&gt;
&lt;li&gt;z-fasta, mzarc, zigR, HarmonizePy, and all the Zig experiments (2026)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;p&gt;Each migrated post got the same treatment: convert the frontmatter, assign the right kind, add shortcodes where they improved readability. The ones that needed correction got retroactive sidenotes. The ones that still hold up got left alone.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-next&quot;&gt;What is next&lt;&#x2F;h2&gt;
&lt;p&gt;Several things I want to add before I call this finished. A comments section, if I can find one that does not require a JavaScript framework. Analytics that count visits without counting visitors. Cloudflare if I move the DNS there. A proper RSS template. Per-page meta tags for social previews. And the presentations and teaching pages need the same treatment the blog just got.&lt;&#x2F;p&gt;
&lt;p&gt;The blog itself will keep growing. The project page needs a compression benchmark comparison. I want to write up the SQuAPP workflow properly. There is a half-finished post about why I keep building things in Zig instead of using existing tools.&lt;&#x2F;p&gt;
&lt;p&gt;But mostly I want to use this thing. The site is built, the branch is merged, and the publishing pipeline is a single push away.&lt;&#x2F;p&gt;
&lt;p&gt;The rest is just writing.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>HarmonizePy: HarmonizR&#x27;s Approach, in Python</title>
        <published>2026-05-28T00:00:00+00:00</published>
        <updated>2026-05-28T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-harmonizepy-made-public/"/>
        <id>https://eneskemalergin.github.io/blog/note-harmonizepy-made-public/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-harmonizepy-made-public/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;HarmonizePy is public.&lt;&#x2F;strong&gt; Pure-Python batch correction for omics data, built around HarmonizR&#x27;s core idea: handle structural missingness by grouping features by observed batch support, correcting compatible subsets, and reassembling without imputation. &lt;code&gt;pip install harmonizepy&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;HarmonizR&#x27;s idea is simple once you see it. Standard ComBat and limma need dense matrices. Real proteomics data has structured missingness (a peptide might be quantifiable in batches 1 and 2 but absent from batch 3 for reasons that have nothing to do with zero abundance). Most approaches impute first or drop features. Voß et al.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Voß H et al. &quot;HarmonizR enables data harmonization across independent proteomic datasets with appropriate handling of missing values.&quot; &lt;em&gt;Nature Communications&lt;&#x2F;em&gt; 13:3523, 2022.
&lt;&#x2F;span&gt;
 asked a different question: what if you let the missingness structure tell you which features should be corrected together?&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Split the matrix by batch-presence patterns. Each feature joins only the batches that observed it. Correct those sub-matrices independently. Reassemble. No imputation.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That is the idea. Schlumbohm et al.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Schlumbohm S, Neumann JE, Neumann P. &quot;HarmonizR: blocking and singular feature data adjustment improve runtime efficiency and data preservation.&quot; &lt;em&gt;BMC Bioinformatics&lt;&#x2F;em&gt;, 2025.
&lt;&#x2F;span&gt;
 later added blocking and singular-feature rescue. The core approach is the same and it is worth having in Python.&lt;&#x2F;p&gt;
&lt;p&gt;We used HarmonizR in the lab. It worked. The friction was not the algorithm. It was reaching across languages every time. Move data to R, correct, bring it back. Once is fine. When the workflow needs to be shared or automated, that extra hop becomes a reason to skip the correction.&lt;&#x2F;p&gt;
&lt;p&gt;HarmonizePy keeps the pipeline inside Python.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;python&quot;&gt;Python&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from harmonizepy import harmonize

result = harmonize(&quot;data.tsv&quot;, &quot;batch.csv&quot;)&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: The harmonize() entry point.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;That is the main thing. A single call. Same shape as the input. No imputation. No R installation.&lt;&#x2F;p&gt;
&lt;p&gt;The implementation was rebuilt from the method descriptions, not ported from the R source line by line
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Direct ports carry assumptions from the original language without forcing a real understanding of the algorithm. Rebuilding from the papers meant testing against expected outputs, not copying code.
&lt;&#x2F;span&gt;
. It uses NumPy for the core engines, has 628 tests validated against &lt;code&gt;sva::ComBat&lt;&#x2F;code&gt;, &lt;code&gt;limma::removeBatchEffect&lt;&#x2F;code&gt;, and HarmonizR v1.10.0, and is numerically concordant within documented tolerances.&lt;&#x2F;p&gt;
&lt;p&gt;The performance works out well for pure Python
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;On a Ryzen 3950X, HarmonizePy runs 54x to 266x faster than single-core HarmonizR across the benchmarked datasets. The largest gaps are on non-parametric ComBat and SCP-scale data. Details in the Benchmarks wiki page.
&lt;&#x2F;span&gt;
. NumPy&#x27;s BLAS-backed operations avoid R&#x27;s per-iteration overhead, but that was never the goal. The goal was keeping the workflow in one language.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What this changes.&lt;&#x2F;strong&gt; A script that the lab used internally is now a public package with tests, docs, a CLI, and a stable API. The work was making it independent of the original author, of the R runtime, and of the private repository, so the next person who needs to correct batch effects in Python has something to start from.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Making it public does not mean it is finished. Diagnostics, better guidance on when correction is working, and more real-world validation are still useful. What it means is the implementation is open, inspectable, and usable without asking for access.&lt;&#x2F;p&gt;
&lt;p&gt;That was the handoff I wanted.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>pxseek: From a Selenium Script to a PyPI Package</title>
        <published>2026-05-26T00:00:00+00:00</published>
        <updated>2026-05-26T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-pxseek-live-in-pypi/"/>
        <id>https://eneskemalergin.github.io/blog/note-pxseek-live-in-pypi/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-pxseek-live-in-pypi/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;pxseek is on PyPI.&lt;&#x2F;strong&gt; CLI and Python library for querying ProteomeXchange metadata. Filter by species, repository, keywords, date range, instruments. Local caching, JSON and table output. &lt;code&gt;pip install pxseek&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The first version was not a package. It was a small parser from 2024, built to help hunt down pediatric cancer proteomics datasets on ProteomeXchange. I worked on it with a co-op student. The goal was practical: find datasets faster, collect enough metadata to decide what was worth looking at, and avoid doing the same manual search again and again.&lt;&#x2F;p&gt;
&lt;p&gt;It worked, but it relied on Selenium
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Browser automation is the kind of dependency you accept for a prototype and regret in production. Chrome versions change. Driver versions change. Pages change. A tool that helps you today can fail silently tomorrow and you will not know why until you debug the browser.
&lt;&#x2F;span&gt;
. I did not want the lab to depend on that long term.&lt;&#x2F;p&gt;
&lt;p&gt;Before I left my post in Lange Lab, I rewrote the core to use ProteomeCentral metadata directly. No browser. No driver. Just HTTP requests and structured data.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pxseek fetch -o px_datasets.tsv
pxseek filter -i px_datasets.tsv -s &quot;Homo sapiens&quot; -k &quot;cancer&quot; -o shortlist.tsv
pxseek lookup --input shortlist.tsv -o detailed.tsv&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Fetch, filter, and lookup in three commands.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;The rewrite also added local caching, multiple output formats, a Python API, and a CLI that works in scripts. It does not download raw files or process spectra. It finds metadata so you can decide what to download.&lt;&#x2F;p&gt;
&lt;p&gt;Publishing to PyPI changes what the tool is. Instead of &quot;there is a script somewhere,&quot; it becomes &lt;code&gt;pip install pxseek&lt;&#x2F;code&gt;. That matters for documentation. It matters for the next student, analyst, or lab member who needs to ask: &quot;Which human cancer proteomics datasets are available?&quot;&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Why this mattered.&lt;&#x2F;strong&gt; I wanted to leave behind something the lab could keep using without needing me to explain a private script, a browser setup, or a set of manual steps. Small tools that sit between a research need and a little software care are worth packaging properly.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;That is what the PyPI release is. A fragile script turned into something installable, repeatable, and independent of whoever wrote the first version.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Native R Extensions with Zig: Initial Observations</title>
        <published>2026-05-23T00:00:00+00:00</published>
        <updated>2026-05-23T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-zigr-benchmark-harness/"/>
        <id>https://eneskemalergin.github.io/blog/post-zigr-benchmark-harness/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-zigr-benchmark-harness/">&lt;p&gt;People who know me are aware there is no great love lost between me and R. The language has quirks that are genuinely baffling to anyone arriving from nearly anywhere else: 1-indexed vectors, factors that quietly convert to integers at the wrong moment, a data.frame that is simultaneously a list and not a list depending on which function you are calling, and a scoping model that surprises you on a regular basis.&lt;&#x2F;p&gt;
&lt;p&gt;And yet I keep writing R packages. The tidyverse changed what it means to do data work in R. &lt;code&gt;dplyr&lt;&#x2F;code&gt;, &lt;code&gt;ggplot2&lt;&#x2F;code&gt;, the whole pipe-based philosophy, and the serious ongoing work on condition handling and user-facing error messages across the ecosystem. That side of R is genuinely well-designed. A community has built something worth using on top of a language that often fights you, and that deserves credit.&lt;&#x2F;p&gt;
&lt;p&gt;I also have real respect for where Rust is going. The ownership model is the most coherent answer I have seen to the problems that accumulated in C++: manual memory management buried under decades of abstraction, undefined behavior that compilers exploit rather than catch, and a template system that grew into something most people use but few people fully understand. Rust solves those problems correctly. The borrow checker is not inconvenient overhead. It is the point.&lt;&#x2F;p&gt;
&lt;p&gt;So when I started writing R extensions seriously, I tried both directions. And I kept running into the same friction.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-zig&quot;&gt;Why Zig&lt;&#x2F;h2&gt;
&lt;p&gt;When you write an R extension, regardless of language, you end up at the same place: the R C API. SEXP values. PROTECT and UNPROTECT calls. &lt;code&gt;.Call&lt;&#x2F;code&gt; as the entry point. R&#x27;s garbage collector owns your objects and decides when they die. You do not own them. Your extension borrows them from R for the duration of a function call, and then R reclaims them.&lt;&#x2F;p&gt;
&lt;p&gt;This is where Rust&#x27;s ownership story stops buying you what it normally buys. The borrow checker is built for a world where your code owns memory and you need to prove that ownership transfers correctly. In R extensions, R&#x27;s GC is the owner. Your code is always a borrower. Both &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;extendr.github.io&#x2F;&quot;&gt;extendr&lt;&#x2F;a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;yutannihilation.github.io&#x2F;savvy&#x2F;guide&#x2F;&quot;&gt;Savvy&lt;&#x2F;a&gt; handle this through generated wrappers and careful lifetime management, and they do it well. But you are carrying real framework weight to solve a problem that is fundamentally about R&#x27;s memory model, not Rust&#x27;s.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;This is not a criticism of extendr or Savvy. Both are serious projects and the benchmark results below show it. Savvy in particular is strong on BLAS and linear model work. The point is about fit: if you are already working at the C ABI boundary, a language that treats C interop as a first-class citizen with zero overhead has a natural advantage for this specific use case.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Zig treats C interop as a language feature, not a compatibility mode. You can call C functions directly, import C headers with &lt;code&gt;@cImport&lt;&#x2F;code&gt;, and build a shared library that R loads with &lt;code&gt;dyn.load()&lt;&#x2F;code&gt; exactly the way it loads a compiled C extension. No wrapper generation step. No framework between you and the R C API. Just Zig, compiling tight code to the same ABI that &lt;code&gt;.Call&lt;&#x2F;code&gt; has always expected.&lt;&#x2F;p&gt;
&lt;p&gt;Zig also gives you comptime instead of macros, explicit allocators, no hidden control flow, and no hidden allocations. Every allocation is visible. Every error path is explicit. The compile-time guarantees catch things that would be silent bugs in C without requiring the ownership ceremony that Rust demands in a context where R is already the owner.&lt;&#x2F;p&gt;
&lt;p&gt;That is what &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;eneskemalergin&#x2F;zigr&quot;&gt;zigr&lt;&#x2F;a&gt; is built toward.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-benchmark-harness&quot;&gt;The Benchmark Harness&lt;&#x2F;h2&gt;
&lt;p&gt;To test whether the fit translated into actual performance, I built a harness across six backends and twenty-three tasks.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The benchmark code is not yet public. It will be released alongside zigr when the harness stabilizes. All six backends implement the same 23 tasks with the same data shapes and operation types.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;p&gt;The six backends:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Runner&lt;&#x2F;th&gt;&lt;th&gt;Implementation&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;r&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Pure R reference baseline&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;zigr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Native Zig via zigr&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;c_call&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Hand-written C via &lt;code&gt;.Call&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;rcpp&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;C++ via Rcpp&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;extendr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Rust via extendr-generated wrappers&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;savvy&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Rust via Savvy-generated wrappers&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;All six currently pass all 23 shared tasks. Results were refreshed on 2026-05-23.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-numbers-say&quot;&gt;What the Numbers Say&lt;&#x2F;h2&gt;
&lt;p&gt;The runner summary first. Lower geomean is better. The &lt;code&gt;Geomean vs R&lt;&#x2F;code&gt; column is the geometric mean of &lt;code&gt;mean_ms &#x2F; R_baseline_mean_ms&lt;&#x2F;code&gt; across all tasks, so values below &lt;code&gt;1.0x&lt;&#x2F;code&gt; are faster than R overall.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Runner&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Tasks Won&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Geomean vs R&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;zigr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;10&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.475x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Savvy&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;3&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.648x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;extendr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.695x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Rcpp&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.891x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;C .Call&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.935x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;R&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.000x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;



&lt;figure class=&quot;blog-figure&quot; style=&quot;max-width: 92%;&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Horizontal bar chart showing geomean vs R baseline for six backends. zigr scores 0.475x, Savvy 0.648x, extendr 0.695x, Rcpp 0.891x, C .Call 0.935x, R 1.000x.&quot;&gt;Benchmark summary&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 1&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;zigr-benchmark&amp;#x2F;geomean-chart.svg&quot; alt=&quot;Horizontal bar chart showing geomean vs R baseline for six backends. zigr scores 0.475x, Savvy 0.648x, extendr 0.695x, Rcpp 0.891x, C .Call 0.935x, R 1.000x.&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Figure 1. Geometric mean of mean_ms &#x2F; R_baseline_mean_ms across 23 tasks. Values below 1.0x are faster than the R baseline. zigr leads at 0.475x.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;zigr leads overall and wins the most tasks. The geomean hides the actual story though, so here is the breakdown by category.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vector, dataframe, and reduction work.&lt;&#x2F;strong&gt; This is where zigr separates most clearly. &lt;code&gt;05_dataframe&lt;&#x2F;code&gt; (filtering &lt;code&gt;1e5&lt;&#x2F;code&gt; rows) runs in 0.53 ms in zigr against 21.9 ms in R. &lt;code&gt;06_na_prop&lt;&#x2F;code&gt; (NA propagation across &lt;code&gt;1e6&lt;&#x2F;code&gt; values) is 0.15 ms in zigr against 9.7 ms in R. &lt;code&gt;14_rowsums&lt;&#x2F;code&gt; is 0.08 ms against 1.25 ms. These are not marginal wins. The work Zig does here is tight iteration over contiguous memory with no interpreter overhead, and R&#x27;s overhead on large vectorized operations is real and measurable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;BLAS and linear algebra.&lt;&#x2F;strong&gt; Savvy is the surprise here. &lt;code&gt;10_blas_matmul&lt;&#x2F;code&gt; (256x256) runs in 1.18 ms for Savvy against 2.63 ms for zigr. Linear model fitting (&lt;code&gt;13_lm&lt;&#x2F;code&gt;, n=5000, p=20) is 0.35 ms for Savvy against 0.40 ms for zigr. Both are calling into the same BLAS and LAPACK routines underneath. Savvy&#x27;s generated wrapper appears to hit a faster dispatch path for this class of work, and understanding why is on the list.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Classic native extension work.&lt;&#x2F;strong&gt; Fibonacci, sort, random normal, element-wise ops. Rcpp and hand-written C are within noise of each other on most of these, and zigr is not dramatically ahead. This is expected: at this level all backends are bottlenecked on the same libm and sorting routines. The extension language stops mattering when the real work happens in a library call.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The two anomalies.&lt;&#x2F;strong&gt; &lt;code&gt;17_broadcast&lt;&#x2F;code&gt; (vector + scalar, &lt;code&gt;1e7&lt;&#x2F;code&gt;) and &lt;code&gt;19_cumsum&lt;&#x2F;code&gt; (cumulative sum, &lt;code&gt;1e7&lt;&#x2F;code&gt;) are tasks where zigr does not clearly separate from R. The numbers are 47 ms vs 49 ms on broadcast, and 50 ms vs 59 ms on cumsum.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;These two deserve their own investigation. Both operate on large contiguous vectors with simple arithmetic, which is exactly where you would expect Zig to pull ahead. Cache behavior or differences in auto-vectorization between R&#x27;s bytecode compiler and Zig&#x27;s codegen are the likely candidates. No clean answer yet.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The two tasks R wins.&lt;&#x2F;strong&gt; &lt;code&gt;09_protect&lt;&#x2F;code&gt; (PROTECT stress, 49k objects) and &lt;code&gt;23_altrep_sum&lt;&#x2F;code&gt; (ALTREP sum over &lt;code&gt;1:1e7&lt;&#x2F;code&gt;). These are worth reading carefully rather than dismissing.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;I said at the top there is no great love lost between me and R. I am willing to say when I am wrong. Some base R internals are exceptionally well-tuned. The R team has spent real effort on the ALTREP sum path. An ALTREP vector carries metadata that lets operations like &lt;code&gt;sum()&lt;&#x2F;code&gt; short-circuit to a formula rather than iterate. My zigr implementation does not exploit that yet. This is not a loss worth engineering around for v0.0.7.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;p&gt;PROTECT stress measures call overhead more than computation. A small surface is being called many times and the overhead of entering and exiting the Zig shim accumulates. R wins here because there is no shim. That will always be true for micro-benchmarks of this shape.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;Full results: mean wall time in ms, all 23 tasks&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;Lower is better.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Task&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;R&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;zigr&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;C .Call&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Rcpp&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;extendr&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Savvy&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;01_fib&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0038&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0021&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0021&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0017&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0042&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0019&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;02_vectorsum&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;15.5244&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;3.5422&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9.4884&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9.3098&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8.9240&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8.8798&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;03_transpose&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.1433&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9474&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9850&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9755&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9957&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.0388&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;04_strings&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.2369&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.1169&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.2217&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9208&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9038&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8992&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;05_dataframe&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;21.9324&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.5260&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9870&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9909&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.0147&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9692&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;06_na_prop&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9.6852&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1517&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.7801&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.7209&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.8617&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.8829&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;07_parallel&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;15.5052&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.2411&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.8719&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.7538&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.5494&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.5613&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;09_protect&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0016&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.7407&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8232&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.7896&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.9051&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8812&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;10_blas_matmul&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.7278&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.6343&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.9852&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.9449&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;3.8780&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.1761&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;11_crossprod&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0728&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0677&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0668&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0715&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0681&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0689&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;12_cholesky&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.7360&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.5001&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.6791&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.5531&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.0418&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.5954&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;13_lm&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.1645&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.3996&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.4264&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.3907&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.3922&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.3506&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;14_rowsums&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.2502&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0797&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1753&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1746&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1154&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1213&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;15_elem_ops&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;86.8469&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;31.1790&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;30.4700&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;30.0600&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;33.1929&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;30.4818&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;16_rowcol_means&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.9085&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.4686&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8153&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8132&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8161&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.8177&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;17_broadcast&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;48.9904&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;47.2191&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;48.1040&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;48.4642&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;94.5069&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;96.0987&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;18_sort&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;62.7667&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;41.6258&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;36.0792&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;36.6330&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;38.1290&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;36.2273&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;19_cumsum&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;58.8522&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;50.3242&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;51.5503&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;51.5376&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;121.2041&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;104.3952&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;20_rnorm&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;38.1920&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32.1525&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32.3429&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32.1031&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32.1622&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32.5613&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;21_string_nchar&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.6656&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0817&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0832&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0811&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0659&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.1310&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;22_which_na&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.0595&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.3015&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.2631&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.1946&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.1840&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.4138&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;23_altrep_sum&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0019&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0024&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8.9877&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9.0244&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0025&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0023&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;24_altrep_read&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0021&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0022&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0023&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0019&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0023&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.0022&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;h2 id=&quot;beyond-speed&quot;&gt;Beyond Speed&lt;&#x2F;h2&gt;
&lt;p&gt;Wall time is the easiest metric to collect and the easiest to misread alone. Three more dimensions are planned for the next harness version.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Memory.&lt;&#x2F;strong&gt; Peak RSS during each task. Heap bytes allocated, tracked through a custom allocator or &lt;code&gt;mallinfo&lt;&#x2F;code&gt;. Leak detection: run each task 10,000 times in one session and watch the heap. GC pressure: how many GC cycles does each backend trigger? An extension that causes fewer allocations means fewer pauses in long-running R sessions even when individual call times look fast.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;GC pressure is the dimension where Zig&#x27;s explicit allocator model has the most interesting potential. You can choose not to heap-allocate for many operations. Whether that translates into measurably lower GC interruption in practice is something the harness will measure directly.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Safety.&lt;&#x2F;strong&gt; Crash resilience: what happens when an extension receives a NULL SEXP, the wrong SEXPTYPE, a vector with the wrong length, or a negative index? Does the R process crash or recover cleanly? Error propagation: does the extension call &lt;code&gt;Rf_error()&lt;&#x2F;code&gt; and signal a proper R condition, or does it &lt;code&gt;abort()&lt;&#x2F;code&gt;? R should survive an extension error without losing the session. PROTECT audit: static analysis using &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kalibera&#x2F;rchk&quot;&gt;&lt;code&gt;rchk&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;, which specifically checks PROTECT&#x2F;UNPROTECT discipline in R extension C code. Passing &lt;code&gt;rchk&lt;&#x2F;code&gt; clean is harder than it looks, and most extensions do not.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Developer experience.&lt;&#x2F;strong&gt; Compile time from a clean build. Binary size of the resulting &lt;code&gt;.so&lt;&#x2F;code&gt;. Both matter for CRAN distribution where size limits are real and cold installs happen regularly. Cold start: &lt;code&gt;dyn.load()&lt;&#x2F;code&gt; time in a fresh R session. Long-run stability: p99 latency over 50,000 task repetitions to surface latency spikes from background GC or OS scheduler interference.&lt;&#x2F;p&gt;
&lt;p&gt;The goal is a full four-axis comparison matrix: speed, memory, safety, and developer cost. The harness goes public when that picture is complete enough to be useful.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;design-constraints&quot;&gt;Design Constraints&lt;&#x2F;h2&gt;
&lt;p&gt;zigr is built around three explicit refusals.&lt;&#x2F;p&gt;
&lt;p&gt;It does not try to cover every SEXP type. The surface is narrow by intention: numeric vectors, matrices, dataframes, strings, and the foreign function call itself. That covers what appears in real scientific computing extensions. Everything else waits for a concrete use case.&lt;&#x2F;p&gt;
&lt;p&gt;It does not abstract the C ABI. The build artifact is a shared library that &lt;code&gt;dyn.load()&lt;&#x2F;code&gt; treats identically to a compiled C extension. The call goes from R through &lt;code&gt;.Call&lt;&#x2F;code&gt; directly into Zig. No generated wrapper layer. No code to read through when something goes wrong.&lt;&#x2F;p&gt;
&lt;p&gt;It does not grow for completeness. New types and operations go in when they are needed, not because coverage is a goal in itself. The narrow scope and the PROTECT safety guarantees are related: the smaller the surface, the more completely those guarantees can cover it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;closing&quot;&gt;Closing&lt;&#x2F;h2&gt;
&lt;p&gt;A lightning rod does two things. It carries the strike, and it keeps the structure standing.&lt;&#x2F;p&gt;
&lt;p&gt;That is what zigr is trying to be for R extension work. Bring the speed. Stop the bugs from burning the house down.&lt;&#x2F;p&gt;
&lt;p&gt;The benchmark harness will go public alongside the library. v0.0.7 is a proof of concept. The numbers suggest the direction is right.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>QuEStVar v0.1.0 is out</title>
        <published>2026-05-22T00:00:00+00:00</published>
        <updated>2026-05-22T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-questvar-v010/"/>
        <id>https://eneskemalergin.github.io/blog/note-questvar-v010/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-questvar-v010/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;code&gt;pip install questvar[plot,yaml]&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;pypi.org&#x2F;project&#x2F;questvar&#x2F;&quot;&gt;PyPI&lt;&#x2F;a&gt; · &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;eneskemalergin.github.io&#x2F;QuEStVar&#x2F;&quot;&gt;Docs&lt;&#x2F;a&gt; · &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;eneskemalergin&#x2F;QuEStVar&quot;&gt;GitHub&lt;&#x2F;a&gt; · &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.4c00131&quot;&gt;Paper&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The package grew out of the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.4c00131&quot;&gt;2024 J. Proteome Res. paper&lt;&#x2F;a&gt;. The analysis lived in a monolithic GitHub archive until last week. v0.1.0 is the clean API extraction of that work.&lt;&#x2F;p&gt;
&lt;p&gt;The core point is one that keeps getting missed in omics: a non-significant t-test is not evidence of equivalence. It is a failure to reject. QuEStVar runs a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.ncbi.nlm.nih.gov&#x2F;pmc&#x2F;articles&#x2F;PMC5502906&#x2F;&quot;&gt;TOST (two-one-sided t-test)&lt;&#x2F;a&gt; alongside the standard test so the result is always one of three states: differential, equivalent, or genuinely indeterminate. The indeterminate case is important to surface explicitly rather than letting it collapse into &quot;not significant.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The other thing I spent time on is the exclusion tracking. Features excluded before testing (bad CV, missing values, zero variance) appear in their own panel with a breakdown by reason. Most tools drop them silently. Knowing &lt;em&gt;why&lt;&#x2F;em&gt; a feature was excluded matters when you are interpreting the overall result counts.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;python&quot;&gt;Python&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import polars as pl
from questvar import QuestVar

df = pl.read_csv(&quot;data&#x2F;demo_realistic.tsv&quot;, separator=&quot;\t&quot;)
qv = QuestVar(cv_thr=1.0, eq_thr=0.5, df_thr=1.0, p_thr=0.05, correction=&quot;fdr&quot;)
results = qv.test(df, cond_1=[&quot;c1_0&quot;,&quot;c1_1&quot;,&quot;c1_2&quot;], cond_2=[&quot;c2_0&quot;,&quot;c2_1&quot;,&quot;c2_2&quot;])
print(results.summary())&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Minimal single-comparison run.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Power analysis is included from the start because sample size planning under equivalence constraints is different from planning under difference constraints, and I kept getting that question after the paper.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;.plot()&lt;&#x2F;code&gt; call on any results object produces an eight-panel figure. Panel G is the exclusion breakdown.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Eight-panel summary figure from QuEStVar&quot;&gt;QuEStVar v0.1.0&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 1&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;QuEStVar&amp;#x2F;main&amp;#x2F;assets&amp;#x2F;summary_plot.png&quot; alt=&quot;Eight-panel summary figure from QuEStVar&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 1.&lt;&#x2F;strong&gt; Summary plot from a single comparison.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;v0.1.0 is single-comparison only. Multi-comparison support (metadata-driven pair generation, batch execution) is next.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>z-toml Is Stable and Passing</title>
        <published>2026-05-20T00:00:00+00:00</published>
        <updated>2026-05-20T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-ztoml-is-stable/"/>
        <id>https://eneskemalergin.github.io/blog/note-ztoml-is-stable/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-ztoml-is-stable/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;z-toml v0.4.0.&lt;&#x2F;strong&gt; TOML v1.1 parser and writer for Zig 0.16. Struct mapping and raw access, in-place rewriter, passes the toml-test corpus. Small enough to understand, stable enough to depend on.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;z-toml has reached the point where I am comfortable calling it stable for my own use. That does not mean finished forever. It means the main shape is there, the important pieces are tested, and I can start depending on it without feeling like every small change may break the whole design.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;zig&quot;&gt;Zig&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-zig&quot;&gt;const src =
    \\title = &quot;My App&quot;
    \\[server]
    \\port = 8080
;
const root = try toml.parseSlice(gpa, src, null);
defer toml.deinit(root, gpa);

const port = root.get(&quot;server&quot;).?.table.get(&quot;port&quot;).?.integer.value;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Parse a TOML string and access values.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;I started z-toml because I wanted a TOML library I could understand, maintain, and use inside other Zig projects. I did not want configuration parsing to become a fragile side problem every time I worked on something that needed config files.&lt;&#x2F;p&gt;
&lt;p&gt;The main milestones: it parses TOML v1.1, writes TOML back out, supports typed parsing into Zig structs, and has an in-place rewriter for changing values without rebuilding the whole file
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The in-place rewriter is the feature I use most. It preserves comments and formatting for the parts of the file it does not touch, which means I can update a config value programmatically without blowing away the hand-written annotations around it.
&lt;&#x2F;span&gt;
. It also passes the toml-test validation suite
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;toml-test is the official TOML validation corpus maintained by the TOML project. Passing it means the parser handles edge cases like inline tables, array-of-tables, dotted keys, and the various datetime formats correctly. It is the closest thing to a conformance guarantee a TOML library can have.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;The library is starting to feel boring in the good way. I can parse a file, map it into a struct, write it back out, or rewrite one value by path. The tests pass. The behavior is documented enough that future me should not have to rediscover the whole project from scratch.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What is next.&lt;&#x2F;strong&gt; Better documentation, a small CLI, typed serialization, more examples. The library has crossed the line from experiment to usable dependency. The rest is polish, not discovery.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The project is small and it should stay small. It is not trying to become a full configuration framework. It parses, writes, preserves enough formatting for practical edits, and reports useful errors with line and column information. That is enough for now.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>zebrac: Why I Forked poop</title>
        <published>2026-05-05T00:00:00+00:00</published>
        <updated>2026-05-05T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-zebrac-fork-of-poop/"/>
        <id>https://eneskemalergin.github.io/blog/note-zebrac-fork-of-poop/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-zebrac-fork-of-poop/">&lt;p&gt;zebrac is my fork of poop, Andrew Kelley&#x27;s Linux benchmarking tool written in Zig. Andrew also created Zig, which is worth saying out loud because none of this exists without the language. I want to be clear about that from the start. poop already does the main interesting thing: it reports CPU performance counters and memory information for command benchmarks.&lt;&#x2F;p&gt;
&lt;p&gt;I did not fork it because I thought the original missed the point. I forked it because I started needing workflow features while comparing versions of my own tools.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What zebrac adds on top of poop.&lt;&#x2F;strong&gt; JSON output, configurable sample counts, warmup iterations, failure tolerance, better error messages, multi-architecture CI, and a cleaned-up build system. The core is still poop&#x27;s. The additions are the parts I needed day to day.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The first thing I wanted was JSON output. Terminal output is good when I am watching the benchmark run. JSON is better when I want to save results, inspect them later, or feed them into a script. I do not want to manually copy numbers from the terminal every time I benchmark something.&lt;&#x2F;p&gt;
&lt;p&gt;I added warmups because the first few runs of a command can be strange. Cold caches, unloaded files, initial allocations. The first measurement is rarely representative. A &lt;code&gt;--warmup&lt;&#x2F;code&gt; flag lets me discard those early iterations.&lt;&#x2F;p&gt;
&lt;p&gt;The other flags came from real use. &lt;code&gt;--min-samples&lt;&#x2F;code&gt; and &lt;code&gt;--max-samples&lt;&#x2F;code&gt; control how many measurements to collect. &lt;code&gt;--allow-failures&lt;&#x2F;code&gt; lets me benchmark commands that sometimes return non-zero. Better error messages surface when a command cannot be executed at all instead of failing silently
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The error message improvements seem small, but they matter when you are benchmarking across a dozen command variants and one of them has a typo. You want to know which one failed and why, not guess.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;zebrac --json results.json \
       --warmup 3 --min-samples 5 \
       &#x27;.&#x2F;my_tool --input data.bin&#x27;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Typical zebrac invocation.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;The build and CI side also got attention. zebrac tracks Zig through 0.12.0, 0.15.0, and now 0.16.0. The CI cross-compiles for x86-linux, x86_64-linux, aarch64-linux, and riscv64-linux. The &lt;code&gt;build.zig&lt;&#x2F;code&gt; supports &lt;code&gt;-Dstrip&lt;&#x2F;code&gt; and &lt;code&gt;zig build release&lt;&#x2F;code&gt; for multi-target binary releases. None of this changes the benchmark output, but it means the tool can live where the data lives, not just on my machine.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;There is more I want to do. The current version works for my workflow, but the gaps are visible.&lt;&#x2F;p&gt;
&lt;p&gt;I want better shell quoting support. Passing a command with pipes, redirects, or quoted arguments to a benchmarking tool is surprisingly fragile. The shell, the flag parser, and the command string all interact in ways that break on realistic input. I want zebrac to handle the common cases without wrapping everything in a script.&lt;&#x2F;p&gt;
&lt;p&gt;I want better support for comparing two or more commands side by side. Right now I run them separately and compare JSON outputs by hand. A built-in comparison mode would make the workflow tighter.&lt;&#x2F;p&gt;
&lt;p&gt;I want cleaner export formats. Markdown tables, CSV, maybe something that pastes cleanly into a project README or a release note.&lt;&#x2F;p&gt;
&lt;p&gt;I want baseline comparisons. Run a benchmark today, save it as a baseline, run again next week, and see what changed.&lt;&#x2F;p&gt;
&lt;p&gt;I also want better labels and result organization. When you are benchmarking five variants of the same tool across three inputs, keeping the results straight is its own problem.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What zebrac is not.&lt;&#x2F;strong&gt; It is not a replacement for hyperfine or a general benchmarking framework. It is a focused Linux tool built around performance counters. The additions I want to make are about workflow, not scope. I am trying to make the tool I reach for when I need to know whether my code got faster or slower.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The credit belongs to poop for proving that this could be a small Zig tool built around Linux performance counters. zebrac keeps that starting point, then adds the pieces I wanted for my own benchmarking. The next pieces are the ones I still reach for manually.&lt;&#x2F;p&gt;
&lt;p&gt;And thank you to Andrew for Zig. It makes projects like this one fun to build, and that matters.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Month My Body Forced a Priority Review</title>
        <published>2026-04-26T00:00:00+00:00</published>
        <updated>2026-04-26T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-personal-reflection/"/>
        <id>https://eneskemalergin.github.io/blog/note-personal-reflection/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-personal-reflection/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;Personal notes rarely appear here unless they connect to work, research, or something I built. Some things should stay private. This month was different. It forced me to reconsider what I was treating as important.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;This is not meant to be a LinkedIn inspirational post, but a small keepsake of my thought process.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;What follows is what happened, what it made me think about, and why it changed how I look at the next part of my life.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;the-month-that-interrupted-the-plan&quot;&gt;The month that interrupted the plan&lt;&#x2F;h2&gt;
&lt;p&gt;I was supposed to be in transition mode. My current position was ending at the end of May, and interviews were moving in different directions. Some were early. Some were close to final. Data scientist roles, bioinformatician roles, even a few software engineering roles.&lt;&#x2F;p&gt;
&lt;p&gt;I wanted to keep my options open.&lt;&#x2F;p&gt;
&lt;p&gt;Then something unexpected happened. I will not share the medical details here, but I had to stay in the hospital for 16 days. It was uncomfortable, tiring, and much longer than expected.&lt;&#x2F;p&gt;
&lt;p&gt;A hospital stay also gave me too much time to think.&lt;&#x2F;p&gt;
&lt;p&gt;I was away from my normal routine. Work stopped. Planning stopped. A lot of the things that felt urgent before suddenly had to wait, because my body was the thing that needed attention first.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;fear-was-driving-more-than-i-realized&quot;&gt;Fear was driving more than I realized&lt;&#x2F;h2&gt;
&lt;p&gt;Before all of this, I was worried about finding a job. That is a normal thing to worry about, especially when a position is ending and the next step is not fixed yet.&lt;&#x2F;p&gt;
&lt;p&gt;I was applying broadly because I did not want to miss anything. The strategy was flexibility. I was telling myself that flexibility was the smart thing to do.&lt;&#x2F;p&gt;
&lt;p&gt;But I didn&#x27;t realize, some of it was due to fear.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Being stuck in a hospital bed made that easier to see. I had been so focused on not losing momentum that I stopped asking what kind of momentum I actually wanted.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Options mattered, but not every option was connected to the kind of life or work I care about.&lt;&#x2F;p&gt;
&lt;p&gt;That is hard to admit, but it is probably true.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;i-still-care-about-making-good-things&quot;&gt;I still care about making good things&lt;&#x2F;h2&gt;
&lt;p&gt;Being sick did not make me care less about work. It made me more honest about the kind of work I want.&lt;&#x2F;p&gt;
&lt;p&gt;I still care about making things that work. Useful software, meaningful science, and tools that help people do better work. Proteomics is where my expertise is, and the problems still feel worth the effort.&lt;&#x2F;p&gt;
&lt;p&gt;I do not want to drift into work that only looks good from the outside.&lt;&#x2F;p&gt;
&lt;p&gt;Work arrangements are not all the same to me:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Working from home is comfortable&lt;&#x2F;li&gt;
&lt;li&gt;Being close to the people I love matters&lt;&#x2F;li&gt;
&lt;li&gt;Having my own space, my own routine, maybe a cat nearby while I work matters&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These are not small things to me.&lt;&#x2F;p&gt;
&lt;p&gt;After this month, I have less patience for onsite or hybrid requirements that seem to exist mostly for monitoring productivity or justifying office space. Not every job can be remote. Teams have different needs. But my health, comfort, and daily environment matter more than I was letting myself admit.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;i-do-not-want-to-work-under-duress&quot;&gt;I do not want to work under duress&lt;&#x2F;h2&gt;
&lt;p&gt;I want to value happiness more than making money under duress. That sounds simple, but it is not simple when there are bills, uncertainty, and pressure to make the safest possible choice.&lt;&#x2F;p&gt;
&lt;p&gt;I still need to be practical.&lt;&#x2F;p&gt;
&lt;p&gt;At the same time, I do not want fear to choose everything for me:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;I do not want to spend my energy on work that makes me unhappy if I have another choice&lt;&#x2F;li&gt;
&lt;li&gt;I do not want to work for a company that takes open-source projects, builds a product on top of them, and then slowly makes the product worse for the people who depended on the original work&lt;&#x2F;li&gt;
&lt;li&gt;I do not want to do all of this only after I am exhausted, scared, or unhappy&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Maybe that sounds idealistic. Maybe it is.&lt;&#x2F;p&gt;
&lt;p&gt;I have too many ideas and opinions, and too little time. I want to keep building open-source tools. I want to keep writing. I want to keep contributing to science in a way that feels useful and honest.&lt;&#x2F;p&gt;
&lt;p&gt;That is the part that keeps coming back.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;it-made-me-think-about-fragile-things&quot;&gt;It made me think about fragile things&lt;&#x2F;h2&gt;
&lt;p&gt;This month made me think about the people I love. It made me think about how quickly normal life can be interrupted.&lt;&#x2F;p&gt;
&lt;p&gt;I do not want to turn sickness into a lesson too neatly. I do not think everything needs a clean meaning. Being sick was bad. Being a medically interesting case was not fun. Losing control over time, body, and plans was frustrating.&lt;&#x2F;p&gt;
&lt;p&gt;It still changed how I think.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I do not want my work to require me to ignore my life. I do not want ambition that only works when nothing goes wrong. I do not want to make plans that treat health, loved ones, and peace as things I can postpone until later.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;I have done some of that before.&lt;&#x2F;p&gt;
&lt;p&gt;I do not want to keep doing it.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;what-i-am-taking-from-it&quot;&gt;What I am taking from it&lt;&#x2F;h2&gt;
&lt;p&gt;I am not quitting ambition. I am trying to be more careful with it.&lt;&#x2F;p&gt;
&lt;p&gt;I still want to work hard. I still want to build things. I still want to contribute to science and open-source work. I still want to have a career that I can be proud of.&lt;&#x2F;p&gt;
&lt;p&gt;I just want those things to fit inside a life that I actually want to live.&lt;&#x2F;p&gt;
&lt;p&gt;That may mean being more selective. It may mean saying no to some options even if they look good from the outside. It may mean choosing the path that gives me more peace, more time near the people I love, and more room to make things that matter to me.&lt;&#x2F;p&gt;
&lt;p&gt;I do not know exactly what that looks like yet. But I know this:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I do not want fear to be the only reason I choose.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>mzarc v0.0.1: Can Domain-Specific Compression Beat General-Purpose Codecs on Mass Spectra?</title>
        <published>2026-03-16T00:00:00+00:00</published>
        <updated>2026-03-16T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-mzarc-initial/"/>
        <id>https://eneskemalergin.github.io/blog/post-mzarc-initial/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-mzarc-initial/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; mzarc is an early prototype of a domain-specific compression codec for mass spectrometry data in Zig. On one DDA dataset, lossless &lt;code&gt;.mzv1&lt;&#x2F;code&gt; compresses to 27.89 MiB from 75.55 MiB mzML, beating gzip and trailing mzMLb by 11.64 MiB. Lossy at q=4096 hits 13.17 MiB with 0.218% p95 relative intensity error. Decode throughput is 167 MiB&#x2F;s, roughly 27x faster than mzMLb. This is v0.0.1. One dataset. Scalar codec only. The remaining lossless size is almost entirely the exact m&#x2F;z stream.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;why-this-exists&quot;&gt;Why this exists&lt;&#x2F;h2&gt;
&lt;p&gt;I have been thinking about compression for mass spectrometry data since before I started learning Zig. The opinion pieces I wrote over the past few months keep circling the same idea: storage is an unaccounted cost, open formats inflate file sizes, and the tools to fix it exist but nobody adopts them.&lt;&#x2F;p&gt;
&lt;p&gt;At some point I decided to stop writing about it and start building.&lt;&#x2F;p&gt;
&lt;p&gt;mzarc is a question expressed as a codebase: can a codec that knows about mass spectra beat general-purpose compressors, and can it do it with decode speed fast enough to feed a search engine directly?&lt;&#x2F;p&gt;
&lt;p&gt;This is v0.0.1. The answer so far is partial. I am sharing it because the partial answer is interesting, and because the honest version of an early experiment is more useful than a polished launch.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-is&quot;&gt;What this is&lt;&#x2F;h2&gt;
&lt;p&gt;mzarc is a domain-specific, asymmetric compression codec for mzML-derived mass spectrometry spectra. Asymmetric means encode can be slow. Decode must be fast.&lt;&#x2F;p&gt;
&lt;p&gt;The pipeline right now is deliberately narrow:&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mzML -&gt; Python dump tool -&gt; flat binary dump -&gt; Zig codec -&gt; .mzv1 file&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Ingestion pipeline.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Python handles mzML ingestion once. Zig handles the transform and decode repeatedly. This keeps XML parsing out of the codec work. The binary dump is an internal handoff format, not a format anyone should use directly.&lt;&#x2F;p&gt;
&lt;p&gt;The codec stack itself is four scalar transforms composed in sequence:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Quantize:&lt;&#x2F;strong&gt; m&#x2F;z to fixed-point; intensity to log-scale at configurable q.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Delta:&lt;&#x2F;strong&gt; intra-spectrum delta coding on sorted m&#x2F;z arrays.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;FOR bitpack:&lt;&#x2F;strong&gt; frame-of-reference packing with per-spectrum bit widths.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Block:&lt;&#x2F;strong&gt; 128 spectra per block, CRC32 validated, MS1 and MS2 in separate block streams.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;

&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;128 spectra per block fits roughly 14 KB at typical peak density. That sits inside L1 cache, which matters for decode throughput.
&lt;&#x2F;span&gt;
&lt;p&gt;That is the whole thing. No entropy coding yet. No SIMD. No cross-spectrum delta. Those come later, if the scalar baseline justifies them.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-measured&quot;&gt;What I measured&lt;&#x2F;h2&gt;
&lt;p&gt;One dataset: 15HCD_1
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;From PXD075509. 9001 spectra, 2,668,458 total peaks, 917 MS1, 8084 MS2. DDA acquisition on a Thermo instrument.
&lt;&#x2F;span&gt;
. One machine. Ten repeat runs per operation. Benchmarked against mzMLb, MScompress, gzip, and zstd. All tools got the same dump as input so the comparison isolates the codec, not the XML parsing.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;size&quot;&gt;Size&lt;&#x2F;h3&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Bar chart comparing file sizes: mzML 75.55 MiB, dump 30.78 MiB, mzv1 lossless 27.89 MiB, mzv1 lossy 13.17 MiB, gzip 19.82 MiB, zstd 17.85 MiB, mzMLb 16.25 MiB, MScompress 21.63 MiB&quot;&gt;size&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 1&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;mzarc&amp;#x2F;93459bd50137b2fad1862e010712ac0466c29187&amp;#x2F;benchmark&amp;#x2F;plots&amp;#x2F;size_comparison.png&quot; alt=&quot;Bar chart comparing file sizes: mzML 75.55 MiB, dump 30.78 MiB, mzv1 lossless 27.89 MiB, mzv1 lossy 13.17 MiB, gzip 19.82 MiB, zstd 17.85 MiB, mzMLb 16.25 MiB, MScompress 21.63 MiB&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;File size comparison on 15HCD_1. Lossless mzv1 (27.89 MiB) beats gzip (19.82 MiB) and the dump itself (30.78 MiB). Lossy at q=4096 (13.17 MiB) is smaller than mzMLb (16.25 MiB).&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Artifact&lt;&#x2F;th&gt;&lt;th&gt;Size&lt;&#x2F;th&gt;&lt;th&gt;vs mzML&lt;&#x2F;th&gt;&lt;th&gt;vs dump&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:---&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzML&lt;&#x2F;td&gt;&lt;td&gt;75.55 MiB&lt;&#x2F;td&gt;&lt;td&gt;100%&lt;&#x2F;td&gt;&lt;td&gt;245%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;dump (binary flat)&lt;&#x2F;td&gt;&lt;td&gt;30.78 MiB&lt;&#x2F;td&gt;&lt;td&gt;41%&lt;&#x2F;td&gt;&lt;td&gt;100%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mzv1 lossless&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;27.89 MiB&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;37%&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;91%&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mzv1 lossy q=4096&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;13.17 MiB&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;17%&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;43%&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;gzip dump&lt;&#x2F;td&gt;&lt;td&gt;19.82 MiB&lt;&#x2F;td&gt;&lt;td&gt;26%&lt;&#x2F;td&gt;&lt;td&gt;64%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;zstd dump&lt;&#x2F;td&gt;&lt;td&gt;17.85 MiB&lt;&#x2F;td&gt;&lt;td&gt;24%&lt;&#x2F;td&gt;&lt;td&gt;58%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzMLb&lt;&#x2F;td&gt;&lt;td&gt;16.25 MiB&lt;&#x2F;td&gt;&lt;td&gt;22%&lt;&#x2F;td&gt;&lt;td&gt;53%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;MScompress&lt;&#x2F;td&gt;&lt;td&gt;21.63 MiB&lt;&#x2F;td&gt;&lt;td&gt;29%&lt;&#x2F;td&gt;&lt;td&gt;70%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The lossless path clears a low bar: it beats gzip and is smaller than the internal dump itself. That means the transforms are doing real work, not just shuffling bytes. It trails zstd on the dump and mzMLb, which is expected. mzMLb uses HDF5 with blosc:zstd compression. mzarc v0.0.1 uses scalar FOR bitpacking with no entropy coding. The gap is roughly 11.64 MiB, and most of it is in one place
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The m&#x2F;z stream. 17.50 MiB of the 27.89 MiB lossless file. That single stream is larger than the entire mzMLb file (16.25 MiB). If entropy coding can cut it in half, the lossless path wins.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;where-the-bytes-go&quot;&gt;Where the bytes go&lt;&#x2F;h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stream&lt;&#x2F;th&gt;&lt;th&gt;Lossless&lt;&#x2F;th&gt;&lt;th&gt;Lossy q=4096&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:---&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Structural&lt;&#x2F;td&gt;&lt;td&gt;0.04 MiB (0.1%)&lt;&#x2F;td&gt;&lt;td&gt;0.04 MiB (0.3%)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Spectrum metadata&lt;&#x2F;td&gt;&lt;td&gt;0.17 MiB (0.6%)&lt;&#x2F;td&gt;&lt;td&gt;0.17 MiB (1.3%)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;m&#x2F;z stream&lt;&#x2F;td&gt;&lt;td&gt;17.50 MiB (62.8%)&lt;&#x2F;td&gt;&lt;td&gt;9.17 MiB (69.6%)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Intensity stream&lt;&#x2F;td&gt;&lt;td&gt;10.18 MiB (36.5%)&lt;&#x2F;td&gt;&lt;td&gt;3.80 MiB (28.8%)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The m&#x2F;z stream is 17.50 MiB in lossless mode. That is 62.8% of the total file. The intensity stream shrinks from 10.18 MiB to 3.80 MiB under lossy quantization, exactly as designed. The m&#x2F;z stream barely moves between lossless and lossy because the current quantizer preserves m&#x2F;z exactly in both paths. Fixing this requires either lossy m&#x2F;z quantization with controlled bounds, or an entropy coding layer that compresses the delta-encoded m&#x2F;z residuals. Both are on the list. Neither is in v0.0.1.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;throughput&quot;&gt;Throughput&lt;&#x2F;h3&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Horizontal bar chart of throughput in MiB&amp;#x2F;s: mzMLb 3-6, MScompress 4-105, gzip 19-153, zstd 165-580, mzv1 124-168&quot;&gt;throughput&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 2&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;mzarc&amp;#x2F;93459bd50137b2fad1862e010712ac0466c29187&amp;#x2F;benchmark&amp;#x2F;plots&amp;#x2F;performance_overview.png&quot; alt=&quot;Horizontal bar chart of throughput in MiB&amp;#x2F;s: mzMLb 3-6, MScompress 4-105, gzip 19-153, zstd 165-580, mzv1 124-168&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Throughput in MiB&#x2F;s. mzv1 encode and decode both exceed 120 MiB&#x2F;s. mzMLb decode (6.2 MiB&#x2F;s) is 27x slower.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;&#x2F;th&gt;&lt;th&gt;Throughput&lt;&#x2F;th&gt;&lt;th&gt;Time&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:---&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzv1 lossless encode&lt;&#x2F;td&gt;&lt;td&gt;123.7 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.25s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzv1 lossless decode&lt;&#x2F;td&gt;&lt;td&gt;167.2 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.18s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzv1 lossy encode&lt;&#x2F;td&gt;&lt;td&gt;128.9 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.24s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzv1 lossy decode&lt;&#x2F;td&gt;&lt;td&gt;167.9 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.18s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzMLb encode&lt;&#x2F;td&gt;&lt;td&gt;3.4 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;22.5s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;mzMLb decode&lt;&#x2F;td&gt;&lt;td&gt;6.2 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;5.0s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;MScompress encode&lt;&#x2F;td&gt;&lt;td&gt;105.6 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.72s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;MScompress decode&lt;&#x2F;td&gt;&lt;td&gt;4.3 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;7.2s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;zstd dump decode&lt;&#x2F;td&gt;&lt;td&gt;579.7 MiB&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;0.05s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;mzv1 decode at 167 MiB&#x2F;s is 27x faster than mzMLb decode. It is slower than zstd on the dump, which is expected: zstd has years of SIMD-optimized C. The scalar Zig codec has none. The question is whether the gap closes with entropy coding and SIMD, or whether general-purpose compressors will always be faster on decode. I do not know yet.&lt;&#x2F;p&gt;
&lt;p&gt;MScompress decode at 4.3 MiB&#x2F;s is the slowest path in the benchmark. Its threaded encode hits 587 MiB&#x2F;s, which is impressive, but the asymmetry is in the wrong direction for a format designed to be decoded many times.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;How to reproduce these benchmarks&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;Run from the repository root:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;uv run python tools&#x2F;benchmark_v1.py \
  --repeats 10 \
  --external-baselines mzmlb,mscompress \
  --mscompress-benchmark-threaded \
  data&#x2F;PXD075509&#x2F;15HCD_1.mzML
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Output goes to &lt;code&gt;benchmark&#x2F;report.json&lt;&#x2F;code&gt; and &lt;code&gt;benchmark&#x2F;report.md&lt;&#x2F;code&gt;. Plots land in &lt;code&gt;benchmark&#x2F;plots&#x2F;&lt;&#x2F;code&gt;. The data shown here is from commit &lt;code&gt;93459bd5&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;h3 id=&quot;fidelity&quot;&gt;Fidelity&lt;&#x2F;h3&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Multi-panel fidelity plot: m&amp;#x2F;z error near zero for all, intensity error visible only for lossy mzv1&quot;&gt;fidelity&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 3&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;mzarc&amp;#x2F;93459bd50137b2fad1862e010712ac0466c29187&amp;#x2F;benchmark&amp;#x2F;plots&amp;#x2F;fidelity_overview.png&quot; alt=&quot;Multi-panel fidelity plot: m&amp;#x2F;z error near zero for all, intensity error visible only for lossy mzv1&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Fidelity overview. Lossless paths are exact. Lossy q=4096 shows controlled intensity error with m&#x2F;z error at the ppm level.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;Lossless mzv1 round-trips exactly. Every m&#x2F;z value and every intensity survives encode-decode unchanged
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;This is tautological by design. A lossless codec that changed data would be a bug. The claim matters only because it separates the codec correctness from the quantization question, which is where the interesting tradeoffs live.
&lt;&#x2F;span&gt;
. So does the original scan order.&lt;&#x2F;p&gt;
&lt;p&gt;Lossy at q=4096: max absolute m&#x2F;z error is 1.0e-06 Da. Mean absolute intensity error is 695 (raw counts). P95 relative intensity error is 0.218%. P99 is 0.238%. These are controlled errors within the quantization bounds. The lossy tradeoff sweep makes this explicit:&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Line chart: as q increases from 256 to 16384, file size increases from 11.90 to 13.81 MiB, p95 error drops from 3.5% to 0.055%&quot;&gt;tradeoff&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 4&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;mzarc&amp;#x2F;93459bd50137b2fad1862e010712ac0466c29187&amp;#x2F;benchmark&amp;#x2F;plots&amp;#x2F;lossy_tradeoff.png&quot; alt=&quot;Line chart: as q increases from 256 to 16384, file size increases from 11.90 to 13.81 MiB, p95 error drops from 3.5% to 0.055%&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Lossy tradeoff. Higher q preserves more precision at modest size cost. q=16384 gives p95 error of 0.055% at only 0.64 MiB more than q=4096.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;q&lt;&#x2F;th&gt;&lt;th&gt;Size&lt;&#x2F;th&gt;&lt;th&gt;P95 rel intensity error&lt;&#x2F;th&gt;&lt;th&gt;P99 rel intensity error&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;256&lt;&#x2F;td&gt;&lt;td&gt;11.90 MiB&lt;&#x2F;td&gt;&lt;td&gt;3.499%&lt;&#x2F;td&gt;&lt;td&gt;3.813%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;1024&lt;&#x2F;td&gt;&lt;td&gt;12.54 MiB&lt;&#x2F;td&gt;&lt;td&gt;0.874%&lt;&#x2F;td&gt;&lt;td&gt;0.950%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;4096&lt;&#x2F;td&gt;&lt;td&gt;13.17 MiB&lt;&#x2F;td&gt;&lt;td&gt;0.218%&lt;&#x2F;td&gt;&lt;td&gt;0.238%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;16384&lt;&#x2F;td&gt;&lt;td&gt;13.81 MiB&lt;&#x2F;td&gt;&lt;td&gt;0.055%&lt;&#x2F;td&gt;&lt;td&gt;0.059%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;At q=16384 the p95 error is 0.055%. The file is 0.64 MiB larger than q=4096. For archival storage, that cost is near zero.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-did-not-measure&quot;&gt;What I did not measure&lt;&#x2F;h2&gt;
&lt;p&gt;Search impact. This is the measurement that matters most. Do peptide identifications and FDR estimates change after a round-trip through lossy mzv1? The benchmark tracks numeric fidelity. It does not track downstream biological conclusions. That requires running a search engine on the original and round-tripped spectra and comparing the results. I have not done that yet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;assumptions&quot;&gt;Assumptions&lt;&#x2F;h2&gt;
&lt;p&gt;Several assumptions are baked into v0.0.1 that may turn out to be wrong:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One dataset is representative.&lt;&#x2F;strong&gt; 15HCD_1 is DDA on a Thermo instrument. DIA data looks different. timsTOF data looks different. Profile-mode data is an order of magnitude larger. Every conclusion in this post is conditional on one file.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The dump is a fair baseline.&lt;&#x2F;strong&gt; Stripping XML overhead is an obvious first step. It is not a format. The dump baseline shows how much of the size reduction is just removing interchange overhead vs actual compression. The gap between mzML (75.55 MiB) and the dump (30.78 MiB) is the XML tax. The gap between the dump and mzv1 lossless (30.78 to 27.89 MiB) is the codec doing real work. That gap is 2.89 MiB. It is real. It is small.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Scalar FOR is enough.&lt;&#x2F;strong&gt; The current size is dominated by the exact m&#x2F;z stream. Entropy coding (rANS, tANS) should shrink that stream significantly. Cross-spectrum delta should help on DIA where consecutive spectra share precursors. Both are unimplemented. If they do not close the gap to mzMLb, the thesis is in trouble.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Python is an acceptable dependency.&lt;&#x2F;strong&gt; The prototype ingests mzML through pyteomics. This is fine for benchmarking. It is not fine for production. A native Zig mzML reader is on the roadmap. It is not in v0.0.1.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Decode speed matters more than encode.&lt;&#x2F;strong&gt; This is the asymmetric design bet. Encode happens once per file. Decode happens every time a search engine reads the data. If decode is not fast enough to feed a search engine without becoming the bottleneck, the format is not useful.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;&#x2F;h2&gt;
&lt;p&gt;This is v0.0.1. The list of things not yet done is longer than the list of things done:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;One dataset. No DIA. No timsTOF. No multi-instrument validation.&lt;&#x2F;li&gt;
&lt;li&gt;No entropy coding. The m&#x2F;z stream is delta-encoded and FOR-packed but not entropy-coded. That is the largest remaining compression opportunity.&lt;&#x2F;li&gt;
&lt;li&gt;No SIMD. The decode path is scalar. SIMD FOR unpack should roughly double decode throughput.&lt;&#x2F;li&gt;
&lt;li&gt;No cross-spectrum delta. Consecutive DIA spectra share precursors. Encoding differences between spectra rather than absolute values should reduce the m&#x2F;z stream significantly.&lt;&#x2F;li&gt;
&lt;li&gt;No search impact measurement. Numeric fidelity is not the same as biological fidelity.&lt;&#x2F;li&gt;
&lt;li&gt;No native mzML reader. Python dependency for ingestion.&lt;&#x2F;li&gt;
&lt;li&gt;No comparison against MS-Numpress. It is the most natural baseline for array-level compression inside mzML and should be added to the benchmark.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;&#x2F;h2&gt;
&lt;p&gt;The immediate next steps are narrow:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Fix the m&#x2F;z stream. Entropy coding first. RANS or tANS. If the m&#x2F;z stream shrinks from 17.50 MiB to something closer to 5-7 MiB, the lossless path beats mzMLb. That is the threshold that decides whether to keep going.&lt;&#x2F;li&gt;
&lt;li&gt;Add a second dataset. DIA on a different instrument. If the codec assumptions break on DIA data, that is better learned now than after months of optimization.&lt;&#x2F;li&gt;
&lt;li&gt;Measure search impact. Run MSFragger or DIA-NN on original and round-tripped spectra. If peptide IDs and FDR are unchanged at q=16384, the lossy path is viable. If they drift at any q, the quantization scheme needs revision.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;After that, the project either has evidence to continue or evidence to stop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-this-fits&quot;&gt;Where this fits&lt;&#x2F;h2&gt;
&lt;p&gt;mzarc is the second Zig tool I have shipped
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The first was &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;eneskemalergin&#x2F;z-fasta&quot; target=&quot;_blank&quot;&gt;z-fasta&lt;&#x2F;a&gt;, a FASTA indexer that runs 9-17x faster than samtools. I wrote about it &lt;a href=&quot;&#x2F;blog&#x2F;post-zfasta-idea&#x2F;&quot; target=&quot;_blank&quot;&gt;here&lt;&#x2F;a&gt;.
&lt;&#x2F;span&gt;
. z-fasta proved that a single static binary could beat established tools on a narrow, well-defined problem. mzarc is trying to prove something harder: that domain-specific encoding can beat general-purpose compression on a format that matters to my field.&lt;&#x2F;p&gt;
&lt;p&gt;The honest assessment after v0.0.1 is that the thesis is not yet proven. The lossless path trails mzMLb. The decode path is fast but scalar-only. The dataset coverage is one file. The search impact is unmeasured.&lt;&#x2F;p&gt;
&lt;p&gt;But the architecture is sound. The codec composes cleanly. The transform chain round-trips exactly. The benchmark pipeline is reproducible. The byte accounting points directly at the remaining gap. These are good foundations.&lt;&#x2F;p&gt;
&lt;p&gt;The next version will either close the gap to mzMLb or explain why it cannot. Either outcome is useful.&lt;&#x2F;p&gt;
&lt;p&gt;Open source (MIT) at github.com&#x2F;eneskemalergin&#x2F;mzarc.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>z-fasta: Indexing FASTA 17x Faster, and All the Things It Still Cannot Do</title>
        <published>2026-02-28T00:00:00+00:00</published>
        <updated>2026-02-28T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-zfasta-idea/"/>
        <id>https://eneskemalergin.github.io/blog/post-zfasta-idea/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-zfasta-idea/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; z-fasta indexes FASTA files 9-17x faster than samtools while producing byte-identical &lt;code&gt;.fai&lt;&#x2F;code&gt; output. It is a zero-dependency static binary written in Zig. It handles 20&#x2F;20 edge cases correctly. It has a streaming mode that uses 4 MB of RAM.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;why-this-exists&quot;&gt;Why this exists&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;samtools faidx&lt;&#x2F;code&gt; is the standard. It works. It is correct. It is also slow.&lt;&#x2F;p&gt;
&lt;p&gt;On a 3 GB human genome, &lt;code&gt;samtools faidx&lt;&#x2F;code&gt; takes 9.2 seconds on warm cache. Run it once and you do not notice. Run it in a pipeline that indexes hundreds of files and you wait.&lt;&#x2F;p&gt;
&lt;p&gt;I had been learning Zig for a few months. The language&#x27;s strengths (no hidden control flow, explicit memory, direct SIMD) map directly onto the problem. A FASTA indexer is not complex. Scan bytes for lines starting with &lt;code&gt;&amp;gt;&lt;&#x2F;code&gt;, record offsets, write them out. The bottleneck is how fast you move bytes from disk to CPU.&lt;&#x2F;p&gt;
&lt;p&gt;I wanted to see how close to the hardware limit I could get.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-z-fasta-does&quot;&gt;What z-fasta does&lt;&#x2F;h2&gt;
&lt;p&gt;z-fasta is a drop-in replacement for &lt;code&gt;samtools faidx&lt;&#x2F;code&gt;. It emits byte-identical &lt;code&gt;.fai&lt;&#x2F;code&gt; output. It also writes &lt;code&gt;.zfi&lt;&#x2F;code&gt;, a compact binary index for programmatic use.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;zig build -Doptimize=ReleaseFast

# Emit samtools-compatible .fai to stdout

z-fasta index --emit-fai genome.fa &gt; genome.fai

# Or create a binary .zfi index

z-fasta index genome.fa&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: Build and run.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Three modes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Default:&lt;&#x2F;strong&gt; mmap + SIMD scanning, with duplicate header detection.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No-dedup:&lt;&#x2F;strong&gt; mmap + SIMD, no duplicate tracking. Fastest. Use when you trust your input.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Low-memory:&lt;&#x2F;strong&gt; chunked reader, 4 MB buffer. For constrained machines where mmap is not available.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;performance&quot;&gt;Performance&lt;&#x2F;h2&gt;
&lt;p&gt;Benchmarked against samtools, seqkit (Go), and fastahack (C++) on three real datasets. All tests on an AMD Ryzen 9 3950X with warm cache, using hyperfine.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Bar chart comparing indexing time in seconds for z-fasta, samtools, seqkit, and fastahack on genome, proteome, and transcriptome datasets&quot;&gt;benchmark&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 1&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;z-fasta&amp;#x2F;ee2b23d3ff6e7e783f37c17bb838b0b3bbc49407&amp;#x2F;bench&amp;#x2F;results&amp;#x2F;figures&amp;#x2F;performance.png&quot; alt=&quot;Bar chart comparing indexing time in seconds for z-fasta, samtools, seqkit, and fastahack on genome, proteome, and transcriptome datasets&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Indexing time in seconds across genome (3.0 GB), proteome (66 MB), and transcriptome (972 MB) datasets. Lower is better.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Dataset&lt;&#x2F;th&gt;&lt;th&gt;Size&lt;&#x2F;th&gt;&lt;th&gt;z-fasta (no-dedup)&lt;&#x2F;th&gt;&lt;th&gt;samtools&lt;&#x2F;th&gt;&lt;th&gt;seqkit&lt;&#x2F;th&gt;&lt;th&gt;fastahack&lt;&#x2F;th&gt;&lt;th&gt;Speedup vs samtools&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:---&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Genome&lt;&#x2F;td&gt;&lt;td&gt;3.0 GB&lt;&#x2F;td&gt;&lt;td&gt;0.57s&lt;&#x2F;td&gt;&lt;td&gt;9.15s&lt;&#x2F;td&gt;&lt;td&gt;5.42s&lt;&#x2F;td&gt;&lt;td&gt;21.71s&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;16.1x&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Transcriptome&lt;&#x2F;td&gt;&lt;td&gt;972 MB&lt;&#x2F;td&gt;&lt;td&gt;0.10s&lt;&#x2F;td&gt;&lt;td&gt;1.79s&lt;&#x2F;td&gt;&lt;td&gt;1.76s&lt;&#x2F;td&gt;&lt;td&gt;5.51s&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;17.5x&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Proteome&lt;&#x2F;td&gt;&lt;td&gt;66 MB&lt;&#x2F;td&gt;&lt;td&gt;0.006s&lt;&#x2F;td&gt;&lt;td&gt;0.05s&lt;&#x2F;td&gt;&lt;td&gt;0.11s&lt;&#x2F;td&gt;&lt;td&gt;0.25s&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;9.4x&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;z-fasta is faster than every tool on every dataset. The gap widens with file size.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Line chart of indexing time vs file size for z-fasta, samtools, seqkit, and fastahack, from 1 MB to 1000 MB&quot;&gt;scaling&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 2&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;z-fasta&amp;#x2F;ee2b23d3ff6e7e783f37c17bb838b0b3bbc49407&amp;#x2F;bench&amp;#x2F;results&amp;#x2F;figures&amp;#x2F;scaling_size.png&quot; alt=&quot;Line chart of indexing time vs file size for z-fasta, samtools, seqkit, and fastahack, from 1 MB to 1000 MB&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Indexing time vs file size. z-fasta and samtools both scale linearly. z-fasta’s slope is an order of magnitude shallower.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;z-fasta and samtools both scale linearly with file size. z-fasta&#x27;s slope is an order of magnitude shallower. At 1 GB, samtools takes 3 seconds. z-fasta takes 0.2 seconds.&lt;&#x2F;p&gt;
&lt;p&gt;Sequence count has almost no effect. At 100,000 sequences, z-fasta takes 0.02 seconds in no-dedup mode. The work is I&#x2F;O-bound, not header-bound.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;simd-newline-scanning&quot;&gt;SIMD newline scanning&lt;&#x2F;h3&gt;
&lt;p&gt;A FASTA indexer spends almost all of its time looking for &lt;code&gt;\n&lt;&#x2F;code&gt;. z-fasta uses Zig&#x27;s &lt;code&gt;@Vector&lt;&#x2F;code&gt; types to scan 32 bytes at a time. On x86_64 this compiles to AVX2 vector compares. A 3 GB genome is one pass at memory bandwidth.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;mmap-by-default&quot;&gt;mmap by default&lt;&#x2F;h3&gt;
&lt;p&gt;z-fasta memory-maps the entire file. The OS handles buffering. The CPU sees a flat byte array. No &lt;code&gt;read()&lt;&#x2F;code&gt; calls in userspace. No buffer management.&lt;&#x2F;p&gt;
&lt;p&gt;The tradeoff is that &lt;code&gt;time&lt;&#x2F;code&gt; reports VmRSS equal to the file size. The OS maps the file to virtual memory and &lt;code&gt;time&lt;&#x2F;code&gt; counts it
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The working set during indexing is a fraction of what VmRSS reports. The OS does not actually read the whole file into RAM.
&lt;&#x2F;span&gt;
. Actual private heap allocation is small: roughly 45 MB for the header hash map in default mode, under 1 MB in no-dedup mode.&lt;&#x2F;p&gt;
&lt;p&gt;If you cannot afford even the virtual memory footprint, &lt;code&gt;--low-mem&lt;&#x2F;code&gt; switches to a chunked reader with a 4 MB buffer. It is 3-4x slower than mmap but uses essentially no memory.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;&#x2F;th&gt;&lt;th&gt;Time (Genome)&lt;&#x2F;th&gt;&lt;th&gt;Heap&lt;&#x2F;th&gt;&lt;th&gt;RSS (reported)&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:---&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;td&gt;---:&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;no-dedup&lt;&#x2F;td&gt;&lt;td&gt;0.57s&lt;&#x2F;td&gt;&lt;td&gt;&amp;lt; 1 MB&lt;&#x2F;td&gt;&lt;td&gt;~3 GB (mmap)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;default&lt;&#x2F;td&gt;&lt;td&gt;0.57s&lt;&#x2F;td&gt;&lt;td&gt;~45 MB&lt;&#x2F;td&gt;&lt;td&gt;~3 GB (mmap)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;low-mem&lt;&#x2F;td&gt;&lt;td&gt;2.44s&lt;&#x2F;td&gt;&lt;td&gt;4 MB&lt;&#x2F;td&gt;&lt;td&gt;4 MB&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;samtools&lt;&#x2F;td&gt;&lt;td&gt;9.15s&lt;&#x2F;td&gt;&lt;td&gt;~3 MB&lt;&#x2F;td&gt;&lt;td&gt;~3 MB&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;correctness&quot;&gt;Correctness&lt;&#x2F;h2&gt;
&lt;p&gt;Speed means nothing if the output is wrong. I tested z-fasta against samtools on 20 edge cases: zero-byte files, missing trailing newlines, mixed &lt;code&gt;\r\n&lt;&#x2F;code&gt; endings, unicode headers, binary garbage mid-file, tab characters in sequence names, sequences with no line wrapping.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Heatmap showing pass&amp;#x2F;fail for each tool across 20 edge cases: z-fasta and samtools both fail on the same 4 cases, seqkit and fastahack deviate&quot;&gt;correctness&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 3&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;eneskemalergin&amp;#x2F;z-fasta&amp;#x2F;ee2b23d3ff6e7e783f37c17bb838b0b3bbc49407&amp;#x2F;bench&amp;#x2F;results&amp;#x2F;figures&amp;#x2F;edge_cases.png&quot; alt=&quot;Heatmap showing pass&amp;#x2F;fail for each tool across 20 edge cases: z-fasta and samtools both fail on the same 4 cases, seqkit and fastahack deviate&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Edge case heatmap. Green = pass, red = fail. z-fasta matches samtools on all 20 cases, including exit codes for errors.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;&lt;strong&gt;Result: 20&#x2F;20 edge cases match samtools behavior exactly&lt;&#x2F;strong&gt;, including exit codes for error cases. seqkit silently accepts some malformed inputs that both samtools and z-fasta reject.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;honest-limitations&quot;&gt;Honest Limitations&lt;&#x2F;h2&gt;
&lt;p&gt;This is a proof of concept. It indexes FASTA and nothing else.&lt;&#x2F;p&gt;
&lt;p&gt;It was built with Zig 0.14.0
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The language is still pre-1.0 and changes between releases can break builds. Migrating to a newer version means updating the build.zig and adapting to any stdlib changes. The core SIMD and mmap logic is portable, but the build configuration and CLI parsing are tied to the version used here.
&lt;&#x2F;span&gt;
. The language moves fast and I have not migrated to a newer version yet. The build will break if you try with a different Zig release.&lt;&#x2F;p&gt;
&lt;p&gt;No gzip support. No FASTQ support. No BED. No sub-sequence extraction. The benchmarks show impressive numbers because the tool does one narrow thing and does not worry about the rest. That is honest but it is also limited.&lt;&#x2F;p&gt;
&lt;p&gt;The Rust ecosystem has several FASTA indexing libraries. &lt;code&gt;rust-htslib&lt;&#x2F;code&gt; wraps htslib and provides FASTA indexing through it. &lt;code&gt;needletail&lt;&#x2F;code&gt; is a streaming FASTA&#x2F;Q parser with speed claims. Both are API libraries, not CLI tools. I chose not to deal with Cargo build complexity and Rust&#x27;s CLI tooling ecosystem for what I wanted as a learning project. That is a personal constraint, not a technical judgment.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-next&quot;&gt;What is next&lt;&#x2F;h2&gt;
&lt;p&gt;The current v0.1.0 only indexes. The repository
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;eneskemalergin&#x2F;z-fasta&quot; target=&quot;_blank&quot;&gt;github.com&#x2F;eneskemalergin&#x2F;z-fasta&lt;&#x2F;a&gt;. The README has a more detailed roadmap.
&lt;&#x2F;span&gt;
 already has a roadmap for what comes after:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;z-fasta get&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; - O(1) sub-sequence extraction by name or region. The other half of &lt;code&gt;samtools faidx&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;z-fasta bed&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; - Extract sequences for every entry in a BED file in a single pass.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;z-fasta digest&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; - In-silico trypsin digestion. If the scanner already moves through FASTA at memory bandwidth, computing peptide masses during the scan is a natural extension.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Gzip support&lt;&#x2F;strong&gt; - Requires a decompression library. I have not committed to the complexity yet.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These exist as plans, not code. The tool is fast at indexing. It needs to be useful at more than that before it replaces anything in a real pipeline.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;where-i-think-this-can-go&quot;&gt;Where I think this can go&lt;&#x2F;h2&gt;
&lt;p&gt;z-fasta is a small tool that does one thing correctly and fast. It is also the first step in a larger idea: a suite of high-performance bioinformatics utilities in Zig. The opinion pieces I have been writing argue that Zig fits the boring foundation layer: parsers, indexers, validators. z-fasta is the first proof that the performance argument holds.&lt;&#x2F;p&gt;
&lt;p&gt;It is not a replacement for samtools. Not yet. Maybe not ever. It is a demonstration that a small, focused tool in a systems language can beat a mature, general-purpose tool on its own turf. Whether that matters depends on whether the rest of the functionality gets built.&lt;&#x2F;p&gt;
&lt;p&gt;Open source (MIT) at github.com&#x2F;eneskemalergin&#x2F;z-fasta.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Storage Crisis Nobody Budgets For</title>
        <published>2026-02-21T00:00:00+00:00</published>
        <updated>2026-02-21T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/opinion-proteomics-storage-issue/"/>
        <id>https://eneskemalergin.github.io/blog/opinion-proteomics-storage-issue/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/opinion-proteomics-storage-issue/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; Proteomics data is growing faster than the storage budgets that are supposed to hold it. New instruments produce deeper coverage per run. Single-cell work multiplies the sample count. AI demand is driving up the cost of storage hardware. The format debate between XML and binary is a distraction. Total data volume is the real problem, and nobody is accountable for it.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;I built a storage server for the lab six months ago. 86 TB of RAID capacity. An Orbitrap Astrals and a timsTOF Ultra 2 feed into it. It is already 60% full.&lt;&#x2F;p&gt;
&lt;p&gt;There is cold storage for older runs. We also lost data there, so it is not quite a solution.&lt;&#x2F;p&gt;
&lt;p&gt;This is not an isolated story. A Reddit user processing Astral data put it bluntly: &quot;You&#x27;ll need to find a data storage solution because buying 10TB hard drives isn&#x27;t sustainable.&quot; This is from someone who just bought a multi-million dollar instrument. The storage problem was an afterthought.&lt;&#x2F;p&gt;
&lt;p&gt;This is not a dramatic story. It is math.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-numbers-are-not-on-our-side&quot;&gt;The numbers are not on our side&lt;&#x2F;h2&gt;
&lt;p&gt;Per-run depth is increasing fast. The Orbitrap Astral can identify over 8,000 protein groups from a single HeLa run and over 15,000 from a fractionated sample in under 5 hours
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Thermo&#x27;s &lt;a href=&quot;https:&#x2F;&#x2F;assets.thermofisher.com&#x2F;TFS-Assets&#x2F;CMD&#x2F;Specification-Sheets&#x2F;ps-001728-ms-orbitrap-astral-ms-ps001728.pdf&quot; target=&quot;_blank&quot;&gt;Orbitrap Astral datasheet&lt;&#x2F;a&gt; claims &amp;gt;8,000 protein groups from a 5.5-min HeLa run. Confirmed independently by &lt;a href=&quot;https:&#x2F;&#x2F;www.nature.com&#x2F;articles&#x2F;s41467-024-51274-0&quot; target=&quot;_blank&quot;&gt;Nature Communications (2024)&lt;&#x2F;a&gt; mapping ~30,000 phosphosites in 30 minutes. The &quot;eight proteomes per day&quot; figure cites Jesper Olsen&#x27;s group at the Copenhagen CP.
&lt;&#x2F;span&gt;
. The timsTOF Ultra 2 is competitive on coverage.&lt;&#x2F;p&gt;
&lt;p&gt;More spectra per run means larger files. An Astral DIA file is around 15 GB
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Confirmed by multiple user reports. A &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;vdemichev&#x2F;DiaNN&#x2F;discussions&#x2F;973&quot; target=&quot;_blank&quot;&gt;DIA-NN GitHub discussion (#973, March 2024)&lt;&#x2F;a&gt; documents an Exploris 480 DIA file at 2-3 GB versus an Astral DIA file at ~15 GB. The jump is roughly 5-7x per file between generations.
&lt;&#x2F;span&gt;
. The previous generation (Exploris 480) produced files in the 2-3 GB range. Same experiment. Five times the storage.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# One Astral run

15 GB  experiment_01.raw

# 100 runs per month

1500 GB  monthly_raw

# With converted mzML (uncompressed, ~10x expansion)

15000 GB  monthly_mzML

# After a year (uncompressed mzML)

180 TB  annual_mzML&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: How 15 GB per run becomes a problem nobody planned for.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Sample counts are exploding. A single-cell proteomics experiment can produce thousands of individual measurements across many acquisitions. Terabytes from one study. The field is moving from dozens of samples to hundreds, to thousands. The storage footprint tracks every run linearly.&lt;&#x2F;p&gt;
&lt;p&gt;Multi-omics compounds the problem. Genomics has its own storage crisis. When the same study collects proteomics, transcriptomics, and metabolomics, the storage demand multiplies across modalities.&lt;&#x2F;p&gt;
&lt;p&gt;The result: a single large study can produce 50 terabytes of raw data. Most of it will never be looked at again after the paper is published. All of it has to be stored somewhere.&lt;&#x2F;p&gt;
&lt;p&gt;The PRIDE repository
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1093&#x2F;nar&#x2F;gkae1011&quot; target=&quot;_blank&quot;&gt;Perez-Riverol et al., NAR 53(D1), 2025&lt;&#x2F;a&gt;. PRIDE receives 534 new datasets per month. 47% of all datasets were submitted in the last three years. Growth is accelerating.
&lt;&#x2F;span&gt;
 receives 534 new datasets per month. Globus was added as a transfer protocol because FTP could not handle the file sizes.&lt;&#x2F;p&gt;
&lt;p&gt;Less than 10% of PRIDE&#x27;s public datasets are ever reanalyzed
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;From the &lt;a href=&quot;https:&#x2F;&#x2F;pmc.ncbi.nlm.nih.gov&#x2F;articles&#x2F;PMC11701690&#x2F;&quot; target=&quot;_blank&quot;&gt;2025 PRIDE update paper&lt;&#x2F;a&gt;: &quot;Overall, the number of datasets mentioned as reanalyzed is &amp;lt;10% of the PRIDE public datasets.&quot; Measured by counting dataset accession mentions in EuropePMC.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-conversion-penalty&quot;&gt;The conversion penalty&lt;&#x2F;h2&gt;
&lt;p&gt;Converting vendor formats to open formats is the right thing to do. The default conversion settings also make your storage problem measurably worse.&lt;&#x2F;p&gt;
&lt;p&gt;The mzML format is verbose by design. The MS-Numpress paper
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1074&#x2F;mcp.RA119.001624&quot; target=&quot;_blank&quot;&gt;Teleman et al., MCP (2019)&lt;&#x2F;a&gt;. A naive mzML representation can be 4-fold to 18-fold larger than the vendor original. The paper also developed the MS-Numpress compression schemes that fix this.
&lt;&#x2F;span&gt;
 documented this: a naive mzML conversion grows the file by 4x to 18x compared to the vendor original.&lt;&#x2F;p&gt;
&lt;p&gt;That expansion is not uniform. It depends on the vendor format. Thermo .raw files are compact binary containers. Converting them to uncompressed mzML creates the largest expansion. Bruker timsTOF .d files are already a directory of binary files (TDF&#x2F;TSF). The expansion from Bruker .d to mzML is less dramatic, and many tools
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;fragpipe.nesvilab.org&#x2F;docs&#x2F;tutorial_convert.html&quot; target=&quot;_blank&quot;&gt;FragPipe&#x27;s docs&lt;&#x2F;a&gt; explicitly recommend against converting .d: &quot;we recommend using the raw .d format for Bruker data.&quot; &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;vdemichev&#x2F;DiaNN&quot; target=&quot;_blank&quot;&gt;DIA-NN&lt;&#x2F;a&gt; and &lt;a href=&quot;https:&#x2F;&#x2F;msfragger.nesvilab.org&#x2F;&quot; target=&quot;_blank&quot;&gt;MSFragger&#x2F;IonQuant&lt;&#x2F;a&gt; all read .d natively. Thermo .raw users do not have this option.
&lt;&#x2F;span&gt;
 can read .d natively anyway. The problem is most acute for Thermo users, which is still the majority of the installed base.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Default conversion (no compression)

wine msconvert experiment.raw --mzML

# Result: 150 GB mzML from a 15 GB raw

# With MS-Numpress + zlib

wine msconvert experiment.raw --mzML \
  --zlib --numpress linear \
  --numpress short logged

# Result: ~20 GB mzML, comparable to original raw&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS2: The same data, two conversion paths.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;This is not an argument against open formats. It is an argument that open formats need to be compact by default. mzMLb
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.0c01006&quot; target=&quot;_blank&quot;&gt;Bhamber et al., JPR (2021)&lt;&#x2F;a&gt;. HDF5-based format storing spectra as compressed datasets with XML metadata. Achieves file sizes comparable to vendor formats. Published, standardized, included in ProteoWizard. Rarely used.
&lt;&#x2F;span&gt;
 solves the compression problem while keeping metadata accessible. MS-Numpress reduces mzML size by roughly 61% alone, up to 87% with zlib, and improves read speed by 21% in some configurations.&lt;&#x2F;p&gt;
&lt;p&gt;The tools exist. They are not the default.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-ai-tax&quot;&gt;The AI tax&lt;&#x2F;h2&gt;
&lt;p&gt;Storage has historically gotten cheaper. That long trend is not guaranteed to continue.&lt;&#x2F;p&gt;
&lt;p&gt;AI demand is disrupting the hardware supply chain in ways that hit labs buying storage right now. NAND flash prices increased by roughly 246% during 2025 according to Kingston&#x27;s end-of-year report
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;nand-research.com&#x2F;memory-flash-crisisc-update-march-2026&#x2F;&quot; target=&quot;_blank&quot;&gt;Kingston&#x27;s Cameron Crandall reported NAND wafer pricing up 246% from Q1 2025&lt;&#x2F;a&gt;. &lt;a href=&quot;https:&#x2F;&#x2F;www.forbes.com&#x2F;sites&#x2F;tomcoughlin&#x2F;2026&#x2F;01&#x2F;02&#x2F;digital-storage-and-memory-projections-for-2026-part-2&#x2F;&quot; target=&quot;_blank&quot;&gt;Forbes (January 2026)&lt;&#x2F;a&gt; confirmed some NAND prices more than doubled in under six months. &lt;a href=&quot;https:&#x2F;&#x2F;www.elinfor.com&#x2F;knowledge&#x2F;nand-flash-prices-are-surging-in-2026-what-it-means-for-your-supply-chain-and-how-to-prepare-p-11312&quot; target=&quot;_blank&quot;&gt;TrendForce projects&lt;&#x2F;a&gt; NAND prices rising another 33-38% QoQ in Q1 2026. This is structural, not cyclical.
&lt;&#x2F;span&gt;
. SSDs that were $175 are now $379. 1TB drives that were $40-50 are more than double.&lt;&#x2F;p&gt;
&lt;p&gt;Cloud providers buy drives by the exabyte. GPU manufacturers allocate supply to the AI market first. The downstream effect: the same components proteomics labs depend on cost more than they did two years ago.&lt;&#x2F;p&gt;
&lt;p&gt;A qualified objection: most proteomics archives live on HDD arrays or tape, not high-performance SSDs. NAND prices affect the active storage layer (SSD caching, high-speed analysis nodes) more directly than cold archives. A lab storing everything on spinning disk is partially insulated from NAND volatility.&lt;&#x2F;p&gt;
&lt;p&gt;The broader point stands. Storage infrastructure of all types is getting more expensive. HDD prices are also rising as manufacturers shift factory capacity to meet AI demand. Cloud pricing is complex. Egress and retrieval fees often dwarf storage costs
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;leanopstech.com&#x2F;blog&#x2F;aws-s3-glacier-pricing-2026&#x2F;&quot; target=&quot;_blank&quot;&gt;S3 Glacier Deep Archive is ~$0.00099&#x2F;GB&#x2F;month&lt;&#x2F;a&gt;, but restoring large datasets costs $0.02-0.03&#x2F;GB in retrieval fees plus hours of wait time. A 10 TB restore costs ~$200 before egress. As LeanOps puts it: &quot;$1&#x2F;TB to store, $20K to retrieve&quot; a petabyte.
&lt;&#x2F;span&gt;
. The headline storage rate understates the true cost of keeping data accessible.&lt;&#x2F;p&gt;
&lt;p&gt;A lab buying a storage server in February 2026 is paying more for less capacity than they would have in 2024. That is not speculation. It is the NAND spot price.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;nobody-is-accountable&quot;&gt;Nobody is accountable&lt;&#x2F;h2&gt;
&lt;p&gt;Instrument vendors sell instruments. They do not pay for the storage that holds the data their instruments produce. Software vendors sell analysis tools. They do not pay for storage of intermediate and output files. Grant budgets include line items for instruments and compute. They rarely include realistic line items for long-term data retention.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;How the NIH policy handles storage costs&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;The NIH Data Management and Sharing Policy (effective January 2023) requires a DMS plan for all grant applications. Storage costs can be budgeted during the project period. After the grant ends, the data must persist. The funding does not.&lt;&#x2F;p&gt;
&lt;p&gt;Some institutions provide repository funding, core facility support, or infrastructure grants. The gap is not absolute. It is structural and widespread enough that most labs feel it.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;p&gt;To put numbers on the silence: the UAB Targeted Metabolomics and Proteomics Laboratory estimated that their TripleTOF 5600 generated 1-2 TB of raw data per month
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.uab.edu&#x2F;medicine&#x2F;tmpl&#x2F;services&#x2F;data-storage&quot; target=&quot;_blank&quot;&gt;UAB TMPL data storage page&lt;&#x2F;a&gt;. At their quoted price ($0.15&#x2F;GB&#x2F;month), projected cost was $80,000&#x2F;year. At modern S3 pricing (~$0.023&#x2F;GB&#x2F;month), raw storage drops to ~$550&#x2F;year. The real costs are elsewhere: backup, replication, metadata, retrieval, and the sysadmin time to maintain it all.
&lt;&#x2F;span&gt;
. Their projected cost was $80,000 per year. At modern cloud rates, the raw storage is cheap. The real cost is everything around it.&lt;&#x2F;p&gt;
&lt;p&gt;The hidden expense is operations. Sysadmin time. Backup validation. Data migrations across storage generations. Security compliance. These costs scale with data volume and easily exceed hardware. A facility generating 50 TB&#x2F;year might spend more on the person managing it than on the disks holding it.&lt;&#x2F;p&gt;
&lt;p&gt;The incentive structure reinforces the gap. Nobody gets a paper for efficient storage. The MS-Numpress authors published their work and the tools are available in ProteoWizard. The default conversion settings most researchers use do not enable compression. Journals require data deposition but do not fund the infrastructure. A handful of datasets get most of the download traffic. The rest sit.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;The field optimizes for generation. Not retention.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;h2 id=&quot;the-problem-nobody-wants-to-talk-about&quot;&gt;The problem nobody wants to talk about&lt;&#x2F;h2&gt;
&lt;p&gt;Deletion is politically harder than storage.&lt;&#x2F;p&gt;
&lt;p&gt;Every dataset has an owner. Nobody wants to approve deletion. Nobody wants responsibility if the data becomes useful later. The result is a hoarding equilibrium: keep everything because the cost of deleting the wrong thing is higher than the cost of keeping it.&lt;&#x2F;p&gt;
&lt;p&gt;This is exactly why storage crises emerge gradually. No single decision creates them. They are the accumulated weight of decisions deferred. The server fills up not because someone chose poorly, but because no one chose at all.&lt;&#x2F;p&gt;
&lt;p&gt;Retention policies exist on paper. Enforcement is rare. The field has no culture of intentional deletion. We keep everything until the server forces a decision.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-would-actually-help&quot;&gt;What would actually help&lt;&#x2F;h2&gt;
&lt;p&gt;The format debate between XML and binary is a distraction. Total data volume is the real problem, and it grows regardless of encoding choice. A 50-terabyte study is large in any format.&lt;&#x2F;p&gt;
&lt;p&gt;Compression tooling is the most direct lever. MS-Numpress
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1074&#x2F;mcp.RA119.001624&quot; target=&quot;_blank&quot;&gt;Teleman et al., MCP (2019)&lt;&#x2F;a&gt;. Reduces mzML by ~61% alone, up to 87% with zlib. Also improves read speed by 21%. Ships with ProteoWizard, enabled by a single flag.
&lt;&#x2F;span&gt;
 already works. mzMLb matches vendor format sizes. StackZDPD
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.nature.com&#x2F;articles&#x2F;s41598-022-16812-y&quot; target=&quot;_blank&quot;&gt;StackZDPD, Nature Scientific Reports (2022)&lt;&#x2F;a&gt;. Alternative encoding using difference encoding + zstd. Reduces mzML volume by ~80% with faster decompression than zlib.
&lt;&#x2F;span&gt;
 offers similar ratios. The gap is not invention. It is adoption. Making compressed, indexed formats the default rather than a niche option would reduce storage pressure across the entire field.&lt;&#x2F;p&gt;
&lt;p&gt;Will Kryder&#x27;s Law save us? Probably not this time. Storage density improvements have slowed. The transition from PMR to HAMR has been slow. Meanwhile, instrument throughput is accelerating faster than density improvements. Retention obligations never expire. Raw files are rarely deleted. Reprocessing requirements preserve originals. The old pattern of &quot;storage will catch up&quot; is not keeping pace with how fast this field generates data.&lt;&#x2F;p&gt;
&lt;p&gt;Retention policies need to become explicit. Not every file lives forever. Raw instrument data that has been processed and verified could move to cold storage after a defined window. Search engine intermediates can be regenerated.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# S3 lifecycle rule: raw to cold to delete

{
    &quot;Rules&quot;: [
        {
            &quot;Id&quot;: &quot;proteomics-retention&quot;,
            &quot;Status&quot;: &quot;Enabled&quot;,
            &quot;Transitions&quot;: [
                {&quot;Days&quot;: 90,  &quot;StorageClass&quot;: &quot;STANDARD_IA&quot;},
                {&quot;Days&quot;: 365, &quot;StorageClass&quot;: &quot;GLACIER&quot;}
            ],
            &quot;Expiration&quot;: {&quot;Days&quot;: 1825}
        }
    ]
}&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS3: A lifecycle policy for proteomics data.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Cloud storage tiers make this practical. AWS S3 Glacier Deep Archive costs roughly $0.001&#x2F;GB&#x2F;month, compared to $0.023&#x2F;GB&#x2F;month for Standard. 20x difference for data accessed once a year or less. The field has no standard for retention. Every lab reinvents the policy, or more often, has no policy at all.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-land&quot;&gt;Where I land&lt;&#x2F;h2&gt;
&lt;p&gt;Storage is not a glamorous problem. It is a maintenance problem. The kind that gets ignored until the server is full and someone has to spend a day deciding what to delete.&lt;&#x2F;p&gt;
&lt;p&gt;The cost is real and growing. Instruments produce more data per run. Experiments include more runs. AI demand pushes hardware prices up. Open formats are necessary but inflate storage requirements when used without compression.&lt;&#x2F;p&gt;
&lt;p&gt;The solutions exist. Compression works. Tiered storage policies work. What is missing is the incentive to adopt them, the tooling to make them easy, and the willingness to treat storage as a first-class budget item rather than an afterthought.&lt;&#x2F;p&gt;
&lt;p&gt;This problem will get worse before it gets better. There are opportunities to build tools that make it better. Small, fast, format-aware compression. Indexed access without full decompression. Retention automation that does not require a human to decide what to delete.&lt;&#x2F;p&gt;
&lt;p&gt;Some of those ideas feel worth exploring further.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>ZagPlot: An Experiment in Learning Zig Through Plotting</title>
        <published>2026-02-01T00:00:00+00:00</published>
        <updated>2026-02-01T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-zagplot/"/>
        <id>https://eneskemalergin.github.io/blog/note-zagplot/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-zagplot/">
&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What this is (and is not).&lt;&#x2F;strong&gt; ZagPlot is a private Zig project for learning how plotting libraries work. It is early, messy, and may never be a finished tool. That is fine. The point is what I learn along the way.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;I wanted to understand what happens between &quot;I have some numbers&quot; and &quot;I have a figure.&quot; That can sound simple.. well it apprentely is not.&lt;&#x2F;p&gt;
&lt;p&gt;Even a small plot has more parts than you notice until you have to build them yourself: scales, axes, ticks, labels, margins, colors, data parsing, output formats, and sensible defaults. Each one is a design decision you normally inherit from a library. I wanted to see those decisions surface
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The exercise is similar to writing a parser to understand parsing, or implementing a hash table to understand hash tables. You do not do it because the world needs another hash table. You do it because the implementation teaches you things the interface hides.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;I am not building this because the world needs another plotting library. There are good ones already, across multiple languages. Zig alone has several plotting projects, including ones that draw in the terminal, which is genuinely impressive. ZagPlot is not competing with any of them. It would not make sense to try.&lt;&#x2F;p&gt;
&lt;p&gt;I am building it because I want to learn a few things that keep coming up in my work.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;How do you map data into a visual space? How do you design an API that is flexible enough for real use but simple enough that someone else can read it? How do you keep a library path separate from a CLI path? How do allocators flow through a real system instead of a toy example?&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Those questions are the point. The library is the excuse.&lt;&#x2F;p&gt;
&lt;p&gt;I chose SVG for the output format because it keeps the scope contained. SVG is text. You write it, inspect it, diff it, and open it in a browser. You do not need PNG encoders, font renderers, canvas APIs, or GUI frameworks before you can see whether your axis labels line up. The fewer dependencies I pull in, the more I learn about the actual problem I am trying to understand.&lt;&#x2F;p&gt;
&lt;p&gt;For now, ZagPlot lives on GitHub as a private repository. I expect it to stay that way for a while. The API will change. Names will get renamed. Parts will get deleted. I want room to make mistakes without pretending the project is ready for anyone else.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;The rough shape of what I am aiming for&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;This is not implemented yet. This is the direction I am thinking about.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;zig&quot;&gt;const zag = @import(&amp;quot;zagplot&amp;quot;);
var plot = zag.Scatter.init(allocator);
try plot.load_csv(&amp;quot;data.csv&amp;quot;);
try plot.render(stdout);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Simple, explicit about allocation, works from CLI or as a library. Whether the final API looks anything like this is something I expect to learn by getting it wrong first.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;p&gt;If the library becomes genuinely useful for my own work later, I will expand it. More chart types. Better CSV handling. Cleaner defaults. Proper tests. If it does not, teaching me how plotting libraries think is still a good outcome for a project of this size.&lt;&#x2F;p&gt;
&lt;p&gt;That is enough. A small thing to learn from, not a thing to ship.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The honest version.&lt;&#x2F;strong&gt; I do not know whether ZagPlot becomes something I use, something I throw away, or something I learn from and leave behind. All three are fine outcomes for a project whose main goal is understanding.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>mzBridge: An Early Attempt to Go from Vendor to Open</title>
        <published>2026-01-09T00:00:00+00:00</published>
        <updated>2026-01-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/note-mzbridge/"/>
        <id>https://eneskemalergin.github.io/blog/note-mzbridge/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/note-mzbridge/">&lt;p&gt;Mass spectrometry data often starts in the least convenient place possible: inside a vendor format. Before I can think about models, statistics, compression, search engines, or nice downstream tooling, I first need to ask a boring question. Can I read the file without dragging half a runtime, a vendor DLL, or a fragile conversion chain behind me? 
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;That question bothers me more than it probably should, but for someone who cares about performance and optimization it makes sense.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The mzML reality:&lt;&#x2F;strong&gt; mzML is the format I want to see at the end of the conversion step. It is documented, supported by many tools, and much easier to move between workflows than vendor raw files. The problem is that mzML is often not where the data starts.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;If the original files are Thermo &lt;code&gt;.raw&lt;&#x2F;code&gt;, Bruker &lt;code&gt;.d&lt;&#x2F;code&gt;, or some other vendor format, then the first step is still conversion. That step can take a long time when cohort size grows. It can also come with annoying practical constraints: operating system assumptions, vendor libraries, large runtimes, and tools that are useful but not as simple as they should be.&lt;&#x2F;p&gt;
&lt;p&gt;This is not me saying those tools are bad. ThermoRawFileParser, ProteoWizard, and mzdata-converter exist because people needed a way through the mess. I have used them. They are part of the reason this ecosystem works at all.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Reading the file should not feel like the fragile part.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;For Thermo files, the common path depends on the vendor API or tools built around it. For Bruker &lt;code&gt;.d&lt;&#x2F;code&gt;, things are more open in practice because parts of the format are organized around SQLite
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Bruker&#x27;s &lt;code&gt;.d&lt;&#x2F;code&gt; format uses SQLite databases for some metadata structures, which means you can inspect parts of it with standard SQL tools. This is more open than Thermo&#x27;s binary format, but it is still not the same as having a small reader that treats the data as plain infrastructure.
&lt;&#x2F;span&gt;
. There is a gap between &quot;we can convert this&quot; and &quot;this is boring enough to build on.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;I want the boring version.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;mzBridge is my early attempt to test that idea. The goal is not to build a search engine, a complete replacement for every converter, or a grand universal mass spectrometry platform. The goal is smaller and more annoying: read vendor data directly, turn it into open data, and make the path small enough that it can live inside real workflows.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;What I envising mzBridge to be.&lt;&#x2F;strong&gt; A small native tool that reads vendor mass spectrometry formats and writes open data. Not a search engine. Not a universal converter. A bridge between the format you have and the format you need.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Zig feels interesting for this because the problem is close to the metal. This is binary parsing, file offsets, buffers, compression, validation, and memory layout. It is not the kind of problem where I want a garbage collector making decisions. It is also not the kind of problem where I want to write C and manually hold every sharp edge with my bare hands. I want a small, simple binary that works on any platform.&lt;&#x2F;p&gt;
&lt;p&gt;Zig gives me a middle place that I enjoy.&lt;&#x2F;p&gt;
&lt;p&gt;Explicit allocation. Small binaries. Easy cross-compilation. Good control over structs and bytes. Enough safety checks that I do not feel like every mistake becomes silent memory corruption. Enough directness that I can still see what the program is doing. That is the appeal.&lt;&#x2F;p&gt;
&lt;p&gt;I do not think Zig magically makes this easy. The hard part is not syntax. The hard part is that vendor formats are not designed for independent readers. Some parts can be inferred. Some parts can be validated. Some parts will probably be weird because instrument models, firmware versions, acquisition methods, and software versions all leave their fingerprints in the file.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-warning&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;⚠️&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The legal constraint.&lt;&#x2F;strong&gt; I cannot reverse engineer this from vendor source code. I do not want to touch anything that makes the legal or ethical situation messy. The only version of this project that makes sense is a clean one: public files, observed behavior, independent parsing, documented assumptions, and validation against outputs produced by accepted tools. I may not make this public for a while. I may not put it on GitHub until I understand the risks better. I do not want a DMCA problem over a tool whose purpose is to make scientific data easier to access.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;The practical suspicion is simple. A lot of conversion feels slower and heavier than it needs to be because the path is too layered. Vendor API, managed runtime, wrapper, converter, XML writer, then maybe another tool that reads the XML back in. Each layer makes sense historically. Together they make the first step of analysis feel more expensive than it should.&lt;&#x2F;p&gt;
&lt;p&gt;I want to know how much of that cost is necessary.&lt;&#x2F;p&gt;
&lt;p&gt;Maybe the answer is most of it. Maybe direct parsing runs into too many edge cases. Maybe the format differences across versions make the maintenance burden too high. Maybe the safe public version of this project ends up much smaller than the private experiment. That would still teach me something.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Downstream tools inherit the shape of the input step. If the first step is slow, fragile, platform-specific, or legally awkward, then everything after it starts with that friction. Search engines, compression formats, public repositories, automated pipelines, and reproducible analysis all depend on reading the data first. That should be the least dramatic part of the workflow.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;I do not know yet whether mzBridge becomes a public tool, a private experiment, or just a set of lessons for future projects like mzArc and mzValidate. I know the question is worth testing. Vendor data is where a lot of proteomics begins, and pretending that open science starts only after conversion feels incomplete.&lt;&#x2F;p&gt;
&lt;p&gt;So this is the early note to myself. Try the bridge. Keep it clean. Validate everything. Do not overpromise.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The Landscape of Proteomics Search Engines, and Does a Zig-Based One Make Sense?</title>
        <published>2026-01-02T00:00:00+00:00</published>
        <updated>2026-01-02T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/opinion-search-space/"/>
        <id>https://eneskemalergin.github.io/blog/opinion-search-space/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/opinion-search-space/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; The proteomics search engine space is crowded and genuinely competitive. DIA-NN, Spectronaut, FragPipe, MaxQuant, and Sage each occupy a real niche. Sage proved a systems language can win on throughput, but features lag behind the decade-old tools. Pipeline projects like quantms and nf-core are already solving the integration problem by wrapping engines in reproducible workflows. A Zig-based search engine is not the right next step. A Zig-based workflow engine built around composable libraries might be.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;I kept asking myself whether a Zig-based proteomics search engine makes sense. The answer is probably no. The space is crowded, the incumbents are improving fast, and the hard problems are not about search speed. Why that is the case is more interesting than a simple yes or no.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-landscape-as-of-early-2026&quot;&gt;The landscape as of early 2026&lt;&#x2F;h2&gt;
&lt;p&gt;DDA is not dead, but DIA is where the field is heading. The engines that matter most right now are the ones handling DIA data well.&lt;&#x2F;p&gt;
&lt;p&gt;On the open-source side, DIA-NN
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;DIA-NN is built primarily by Vadim Demichev. Supports GPU and CPU, library-based and library-free modes, reads most vendor formats. Free for academic and commercial use. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;vdemichev&#x2F;DiaNN&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 reset expectations for DIA processing. MaxQuant with MaxDIA handles DIA within the Quant ecosystem. FragPipe
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;FragPipe wraps MSFragger (DDA and DIA search) with DIA-NN (quantification), MSBooster, Percolator, and ProteinProphet in one pipeline. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;fragpipe.nesvilab.org&quot;&gt;fragpipe.nesvilab.org&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 combines MSFragger and DIA-NN in a single platform. Skyline remains the targeted quantification workhorse.&lt;&#x2F;p&gt;
&lt;p&gt;On the commercial side, Spectronaut
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Biognosys. Polished UI, library-based and directDIA modes, subscription licensing. Pushes users toward the proprietary HTRMS format. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;biognosys.com&#x2F;software&#x2F;spectronaut&quot;&gt;biognosys.com&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 has the best UI and turnkey analysis. Proteome Discoverer is Thermo&#x27;s bundled solution, Windows-bound and slower than alternatives. Mascot still exists but feels like legacy infrastructure.&lt;&#x2F;p&gt;
&lt;p&gt;On DDA, MSFragger
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;MSFragger uses fragment ion indexing for fast database search. Open-source, Nesvizhskii lab. Published in &lt;em&gt;Nature Methods&lt;&#x2F;em&gt; (2017). Integrated into FragPipe.
&lt;&#x2F;span&gt;
 changed the speed equation years ago. Sage
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Sage by Michael Lazear. Rust-based, MIT-licensed. Benchmarks faster than MSFragger on most DDA benchmarks. LFQ and TMT quantification, RT prediction, FDR control. Published in &lt;em&gt;JPR&lt;&#x2F;em&gt; (2023). &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;lazear&#x2F;sage&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 is a newer Rust-based engine that benchmarks faster still. One developer, limited development depth. That is not a criticism. It is the reality of a project that started as a personal learning exercise.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-space-is-crowded-and-that-is-a-good-thing&quot;&gt;The space is crowded, and that is a good thing&lt;&#x2F;h2&gt;
&lt;p&gt;This is worth saying plainly. The search engine landscape is not broken. It is competitive, fast-moving, and full of genuine innovation. New tools appear regularly. Old tools improve. Benchmarking papers compare them
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;For example, the 2023 &lt;em&gt;Nature Communications&lt;&#x2F;em&gt; benchmarking of DIA-NN, Spectronaut, MaxDIA, and Skyline across Orbitrap and timsTOF data. More recently, comparisons of Spectronaut vs DIA-NN on lung adenocarcinoma biopsies (Yu &amp;amp; Siu, &lt;em&gt;JPR&lt;&#x2F;em&gt; 2026).
&lt;&#x2F;span&gt;
. The community debates them. This is what a healthy software ecosystem looks like.&lt;&#x2F;p&gt;
&lt;p&gt;If you need to analyze proteomics data today, you have good options. Free ones, fast ones, well-documented ones. The problem is not a lack of tools. The problem is stitching them together.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dia-nn-and-spectronaut-two-models-both-working&quot;&gt;DIA-NN and Spectronaut: two models, both working&lt;&#x2F;h2&gt;
&lt;p&gt;DIA-NN is remarkable. Built mostly by one person, now one of the most-used DIA tools in the field. Free for academic and commercial use. No license wall between institutions. Fast.&lt;&#x2F;p&gt;
&lt;p&gt;Spectronaut has a polished interface, excellent documentation, and dedicated support. It also has a subscription fee, proprietary HTRMS format lock-in, and academic&#x2F;commercial license tiers. The results are good. The cost, the tiering, and the format lock-in are the parts that frustrate me. If a graduate student learns on Spectronaut and moves to a lab that cannot afford the license, their tool stack breaks.&lt;&#x2F;p&gt;
&lt;p&gt;Both models work. DIA-NN proved you do not need a company to build a widely adopted tool. Spectronaut proves commercial polish still commands a market. Neither is going away.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;speed-is-solved-scale-is-not&quot;&gt;Speed is solved. Scale is not&lt;&#x2F;h2&gt;
&lt;p&gt;MSFragger solved raw search speed. Sage pushed it further. Search throughput is no longer what keeps people up at night.&lt;&#x2F;p&gt;
&lt;p&gt;The new bottleneck is cohort scale. At 20 samples, most tools work. At 500, the ones designed for workstations start creaking. At 1,000, the ones not built for headless environments become painful.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;## Run DIA-NN on 500 .raw files

diann --dir data&#x2F; \
      --lib spectral_library.speclib \
      --out report.tsv

## Convert FragPipe output for downstream analysis

python -c &quot;
import pandas as pd
combined = []
for f in glob(&#x27;combined_protein.tsv&#x27;):
    combined.append(pd.read_csv(f, sep=&#x27;\t&#x27;))
pd.concat(combined).to_csv(&#x27;all_results.tsv&#x27;, sep=&#x27;\t&#x27;)
&quot;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: The gap between tools is where the real work lives.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;The problems are not CPU cycles. They are memory management across thousands of identifications, file I&#x2F;O patterns that assume local disk when data lives on network storage, and quantification workflows that break silently when scaled.&lt;&#x2F;p&gt;
&lt;p&gt;Spectronaut handles large cohorts if you pay for the server license. DIA-NN handles them if configured correctly. FragPipe works but the Java GUI adds friction on headless servers. MaxQuant will run, but slowly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;feature-fragmentation-everyone-owns-a-corner&quot;&gt;Feature fragmentation: everyone owns a corner&lt;&#x2F;h2&gt;
&lt;p&gt;Nobody does everything well. Each tool has a signature strength.&lt;&#x2F;p&gt;
&lt;p&gt;MaxQuant owns label-free quantification. MaxLFQ is the algorithm papers cite without thinking. FragPipe bridges DDA and DIA with MSFragger speed. Spectronaut has the best UI and directDIA mode. DIA-NN has speed, openness, and format support. Skyline owns targeted quantification and method building. Sage has raw throughput and cloud-native design but lacks the quantification depth and PTM analysis of tools that have been evolving for a decade.&lt;&#x2F;p&gt;
&lt;p&gt;The niches are deep but narrow. If you need LFQ on a DIA dataset with PTM analysis and batch correction, you are stringing together multiple tools. That works. It is also where the friction lives.&lt;&#x2F;p&gt;
&lt;p&gt;The field rewards novelty. A new quantification method gets a paper. A new search algorithm gets a paper. Nobody gets a paper for making tools work together. The incentive structure produces fragmentation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sage-and-what-it-taught-me&quot;&gt;Sage, and what it taught me&lt;&#x2F;h2&gt;
&lt;p&gt;I followed Sage from its earliest days. It started as a blog post and a simple repository. Michael Lazear was learning Rust and proteomics at the same time. The project grew from a learning exercise into a &lt;em&gt;JPR&lt;&#x2F;em&gt; publication and a tool people actually use in production
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Lazear MR. &quot;Sage: An Open-Source Tool for Fast Proteomics Searching and Quantification at Scale.&quot; &lt;em&gt;J. Proteome Res.&lt;&#x2F;em&gt; 22(11):3652-3659, 2023. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.3c00486&quot;&gt;DOI&lt;&#x2F;a&gt;. MIT-licensed. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;lazear&#x2F;sage&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;Sage proved two things. First, a modern systems language can enter a mature space and win on raw performance. Second, the gap between a fast search engine and a full-featured platform is enormous. Sage is faster than most tools on clean benchmarks. It also has fewer features, less community testing, and narrower format support than the tools it competes with. 
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;That is not a criticism. It is the reality of a project that started as a personal learning exercise. And hasn&#x27;t had the time to evolve into mature tool. The point is the gap between a fast search engine and a complete analysis platform is measured in years of domain-specific development, not in CPU cycles.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;A fast search engine is not the same as a complete analysis platform. The distance between them is measured in years of domain-specific development, not in CPU cycles.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;I watched that trajectory. It made me think about what I could learn by building something similar in Zig. The conclusion I arrived at is not the one I expected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;people-are-already-solving-the-integration-problem&quot;&gt;People are already solving the integration problem&lt;&#x2F;h2&gt;
&lt;p&gt;The gap between tools is real, but groups are working on it.&lt;&#x2F;p&gt;
&lt;p&gt;quantms
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Dai C, Pfeuffer J, Wang H et al. &quot;quantms: a cloud-based pipeline for quantitative proteomics enables the reanalysis of public proteomics data.&quot; &lt;em&gt;Nature Methods&lt;&#x2F;em&gt; 21:1603-1607, 2024. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bigbio&#x2F;quantms&quot;&gt;GitHub&lt;&#x2F;a&gt;. Wraps search engines into reproducible Nextflow pipelines with containers. quantmsdiann wraps DIA-NN for DIA.
&lt;&#x2F;span&gt;
 wraps search engines and quantification tools into reproducible Nextflow pipelines with containerized environments. It supports DDA and DIA workflows and follows nf-core standards.&lt;&#x2F;p&gt;
&lt;p&gt;nf-core&#x2F;proteomicslfq provides LFQ analysis using OpenMS and MSstats. The nf-core community now has dozens of pipelines and thousands of contributors, with a dedicated Mass Spectrometry Proteomics Special Interest Group.&lt;&#x2F;p&gt;
&lt;p&gt;These projects show the path forward. The integration problem is being solved by workflow engines wrapping existing tools in reproducible containers. You do not need to rebuild the search engine. You need to make the engines work together at scale.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-zig-based-engine-probably-not-something-adjacent&quot;&gt;A Zig-based engine? Probably not. Something adjacent&lt;&#x2F;h2&gt;
&lt;p&gt;Sage answered the question of whether a new language can produce a competitive search engine. It can. It also answered the question of whether speed alone wins. It does not.&lt;&#x2F;p&gt;
&lt;p&gt;The hard problems in search engines are quantification algorithms tuned for a decade, FDR control and protein inference that depend on deep domain knowledge, and PTM localization that is as much biochemistry as computation. Building a new engine means rebuilding all of that. The field does not need another fast searcher. It has several.&lt;&#x2F;p&gt;
&lt;p&gt;What I keep coming back to is the workflow engine idea from my earlier thinking
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;See &lt;a href=&quot;https:&#x2F;&#x2F;eneskemalergin.github.io&#x2F;blog&#x2F;opinion-workflow-engine-in-zig&#x2F;&quot;&gt;Workflow Engines and the Case for a Zig-Based One&lt;&#x2F;a&gt;.
&lt;&#x2F;span&gt;
. A Zig-based workflow engine, not a search engine. Something that treats search engines and quantification tools as composable libraries, OpenMS-style
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;OpenMS is a C++ framework for mass spectrometry data analysis with Python bindings (pyOpenMS). Provides modular tools (TOPP) for building custom workflows. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;openms.de&quot;&gt;openms.de&lt;&#x2F;a&gt;. BSD-licensed. Nearly two decades of continuous development.
&lt;&#x2F;span&gt;
, but built from the start for the scale and deployment constraints of modern proteomics.&lt;&#x2F;p&gt;
&lt;p&gt;The value would not be in beating DIA-NN on identifications. It would be in making the glue between tools more reliable and less fragile.&lt;&#x2F;p&gt;
&lt;p&gt;This is a library project, not an engine project. It is also a multi-year effort with uncertain demand. I am not starting it tomorrow. But watching Sage grow from a blog post to a real tool makes the idea feel less abstract.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-land&quot;&gt;Where I land&lt;&#x2F;h2&gt;
&lt;p&gt;The search engine landscape is crowded. It is also healthy. Genuine competition, genuine open-source options, and genuine innovation. The problems are not a lack of good engines. The problems are feature fragmentation, scale challenges, and the integration work of stitching tools together.&lt;&#x2F;p&gt;
&lt;p&gt;Pipeline projects like quantms and nf-core are solving integration with Nextflow and containers. That is probably the right approach for production work. A Zig-based workflow engine that treats proteomics tools as composable building blocks might be a better fit for exploration, for learning, and for the kind of custom pipelines where Makefiles still work until they do not.&lt;&#x2F;p&gt;
&lt;p&gt;I will keep using DIA-NN for most things. I will keep watching Sage grow. I will keep wishing the tools talked to each other better.&lt;&#x2F;p&gt;
&lt;p&gt;And I will probably keep wondering what a modular proteomics toolkit in Zig would look like, even if I never build it.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Vendor-Locked MS Files and Open Formats, a Collision</title>
        <published>2025-12-28T00:00:00+00:00</published>
        <updated>2025-12-28T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/opinion-vendor-locked/"/>
        <id>https://eneskemalergin.github.io/blog/opinion-vendor-locked/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/opinion-vendor-locked/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; Instrument vendors use proprietary file formats. Thermo is the most locked down. Bruker ships an SDK. The problem is not that proprietary formats exist. It is that accessing them requires proprietary converters that gate everything downstream. Open formats are necessary, and they can be binary, compressed, and fast. mzML proved the model works. The next step is making open formats the default, not the conversion target.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;I should be fair to start. Vendors make instruments. Instruments generate data. The data is theirs to format as they see fit. Nobody owes me a CSV.&lt;&#x2F;p&gt;
&lt;p&gt;The gap between how instrument data is stored and how it is used has become a bottleneck. Not in theory. In practice. Every proteomics pipeline I work with starts by converting files before any analysis can begin.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;bash&quot;&gt;Bash&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;## Convert Thermo .raw to mzML before anything else

mono ThermoRawFileParser.exe \
 -i=PXD000001.raw \
 -o=PXD000001.mzML

## Now the real pipeline can start&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: The first step in every pipeline is a toll booth.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;That conversion step is the bottleneck I want to talk about.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-landscape-not-the-villain&quot;&gt;The landscape, not the villain&lt;&#x2F;h2&gt;
&lt;p&gt;Vendors sit on a spectrum. It is more useful than singling out one company.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;&#x2F;th&gt;&lt;th&gt;Example&lt;&#x2F;th&gt;&lt;th&gt;Can you read the data?&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Proprietary format + proprietary reader only&lt;&#x2F;td&gt;&lt;td&gt;Thermo .raw&lt;&#x2F;td&gt;&lt;td&gt;Only through vendor&#x27;s DLLs&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Proprietary format + documented SDK&lt;&#x2F;td&gt;&lt;td&gt;Bruker .d&lt;&#x2F;td&gt;&lt;td&gt;Through SDK, restricted license&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Proprietary format + public specification&lt;&#x2F;td&gt;&lt;td&gt;Rare in MS&lt;&#x2F;td&gt;&lt;td&gt;Anyone can implement a reader&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Open format + multiple independent readers&lt;&#x2F;td&gt;&lt;td&gt;mzML, mzMLb&lt;&#x2F;td&gt;&lt;td&gt;Fully open&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The gap between the first two rows and the last two is where the ecosystem cost lives. Not in the format itself. In the access layer.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;thermo-the-door-is-now-cross-platform-but-still-locked&quot;&gt;Thermo: the door is now cross-platform, but still locked&lt;&#x2F;h2&gt;
&lt;p&gt;Thermo Fisher Scientific is the largest manufacturer of mass spectrometers used in proteomics. Their .raw format is a binary blob readable only through proprietary Windows DLLs. For a long time, that meant Linux users needed a Windows VM or Mono to read their own data.&lt;&#x2F;p&gt;
&lt;p&gt;That has improved. Thermo now ships RawFileReader
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Thermo&#x27;s RawFileReader is a group of .NET assemblies wrapping the ThermoFisher.CommonCore C# libraries. It officially supports Windows, Linux, and macOS through .NET. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;thermofisherlsms&#x2F;RawFileReader&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
, a cross-platform .NET library. ThermoRawFileParser
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Hulstaert N et al. &quot;ThermoRawFileParser: Modular, Scalable, and Cross-Platform RAW File Conversion.&quot; &lt;em&gt;J. Proteome Res.&lt;&#x2F;em&gt; 19(1):537-542, 2020. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;CompOmics&#x2F;ThermoRawFileParser&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 builds on top of it and runs on Linux at scale through .NET Core. You no longer need a Windows VM.&lt;&#x2F;p&gt;
&lt;p&gt;The framing matters here. Linux access arrived late. The original RawFileReader required Windows, and the community spent years building workarounds before Thermo provided a cross-platform path. It is also still dependent on Thermo&#x27;s proprietary stack. If Thermo changed their DLL interface tomorrow, every downstream converter would break. That is not a theoretical risk. It is a structural dependency.&lt;&#x2F;p&gt;
&lt;p&gt;The community keeps finding creative ways to work around the same door. There is a Rust reader that hosts the .NET runtime in-process to call RawFileReader
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;mobiusklein&#x2F;thermorawfilereader.rs&quot;&gt;thermorawfilereader.rs&lt;&#x2F;a&gt; embeds the .NET runtime inside a Rust process. Clever engineering that still depends on Thermo&#x27;s DLLs.
&lt;&#x2F;span&gt;
. Another project, ThermoRawRead, provides a GUI and CLI for extracting spectra
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;thermorawread.ctarn.io&#x2F;&quot;&gt;ThermoRawRead&lt;&#x2F;a&gt; by ctarn is a cross-platform tool built on RawFileReader with a pipeline processing model.
&lt;&#x2F;span&gt;
. All of them route through Thermo&#x27;s reader.&lt;&#x2F;p&gt;
&lt;p&gt;A vendor engineer would say: &quot;We are not protecting a file format. We are protecting correct interpretation of instrument data.&quot; New instruments introduce new detector modes, ion mobility dimensions, and acquisition schemes. If a third-party reader misinterprets the data, users blame the instrument. That concern is legitimate. It also does not require proprietary readers forever. A public specification with conformance tests would serve the same goal.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bruker-easier-to-use-not-truly-open&quot;&gt;Bruker: easier to use, not truly open&lt;&#x2F;h2&gt;
&lt;p&gt;Bruker ships the TDF-SDK
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Bruker TDF-SDK provides C++ and Python bindings on Windows and Linux for reading .tdf and .tsf files. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.bruker.com&#x2F;en&#x2F;products-and-solutions&#x2F;mass-spectrometry&#x2F;ms-software&#x2F;mass-spectrometry-software-updates.html&quot;&gt;Bruker TDF-SDK page&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 with documentation, examples, and cross-platform support. Their timsTOF stores data in SQLite and HDF5 containers. That is more accessible than Thermo&#x27;s binary blob.&lt;&#x2F;p&gt;
&lt;p&gt;But accessible is not the same as open.&lt;&#x2F;p&gt;
&lt;p&gt;The TDF ecosystem still revolves around proprietary Bruker libraries (&lt;code&gt;timsdata.dll&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;libtimsdata.so&lt;&#x2F;code&gt;) in many tools. OpenTIMS
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gtluu&#x2F;OpenTIMS&quot;&gt;OpenTIMS&lt;&#x2F;a&gt; parses portions of the .tdf format directly, including the SQLite components. It exists because people wanted access that was less dependent on Bruker&#x27;s SDK.
&lt;&#x2F;span&gt;
 emerged because the community wanted a path that did not require Bruker&#x27;s SDK. pyTDFSDK
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;gtluu&#x2F;pyTDFSDK&quot;&gt;pyTDFSDK&lt;&#x2F;a&gt; provides a Python wrapper around the TDF-SDK DLL. Still depends on the proprietary library.
&lt;&#x2F;span&gt;
 wraps the SDK DLL.&lt;&#x2F;p&gt;
&lt;p&gt;Bruker protects intellectual property. TIMS is proprietary ion mobility technology that differentiates their instruments. I am not asking them to give that away. The difference from Thermo is real and worth crediting: Bruker decided that making data accessible to third-party tools is better for customers, and by extension better for business. But the dependency is still on a vendor-controlled SDK, not an open specification. That distinction matters.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;not-just-vendors-the-software-middleman-problem&quot;&gt;Not just vendors: the software middleman problem&lt;&#x2F;h2&gt;
&lt;p&gt;Vendors are not the only offenders. Biognosys&#x27;s Spectronaut uses a proprietary format called HTRMS
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;HTRMS is a pre-processed binary format for Spectronaut. Biognosys recommends converting to it for timsTOF data. The converter is free but closed-source. The format specification is not public.
&lt;&#x2F;span&gt;
. The converter is free but closed-source. The format spec is not public.&lt;&#x2F;p&gt;
&lt;p&gt;I should be precise about the harm here. A proprietary internal format that speeds up a specific tool is an engineering choice. The problem is not that HTRMS exists. It is that the format specification is not public, which means the processed data exists in a form only one tool can read. If Spectronaut published the HTRMS layout and documented the encoding, the performance argument would remain and the lock-in would disappear.&lt;&#x2F;p&gt;
&lt;p&gt;DIA-NN
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Demichev V et al. &quot;DIA-NN: neural networks and interference correction enable deep proteome coverage in high throughput.&quot; &lt;em&gt;Nat. Methods&lt;&#x2F;em&gt; 17:41-44, 2020. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;vdemichev&#x2F;DiaNN&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;&#x2F;span&gt;
 demonstrates that a proprietary intermediate format is not necessary for performance. It processes Thermo .raw, Bruker .d, and Sciex .wiff files
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;DIA-NN supports these formats directly from a user perspective. Some of this support may route through vendor SDKs internally. The point is not that DIA-NN is fully independent of vendor code. It is that the workflow does not require a separate conversion step and a proprietary intermediate layer.
&lt;&#x2F;span&gt;
 without requiring users to manage a separate conversion step. Speed and openness are compatible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-reverse-engineering-graveyard&quot;&gt;The reverse-engineering graveyard&lt;&#x2F;h2&gt;
&lt;p&gt;The history of people trying to read Thermo files without Thermo&#x27;s permission is long and mostly sad. Unfinnigan
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;code.google.com&#x2F;archive&#x2F;p&#x2F;unfinnigan&#x2F;&quot;&gt;Unfinnigan&lt;&#x2F;a&gt; was a Google Code project for &quot;painless extraction of mass spectra from Thermo raw files.&quot; The name is a jab at the Thermo Finnigan lineage. Archived.
&lt;&#x2F;span&gt;
 was one of the early attempts. It tried to read raw spectra without a proprietary library. It died.&lt;&#x2F;p&gt;
&lt;p&gt;OpenChrom reads vendor formats natively through reverse-engineered binary readers. ProteoWizard&#x27;s msconvert is the workhorse most pipelines depend on. None of these are small projects. All exist because vendors will not publish their formats.&lt;&#x2F;p&gt;
&lt;p&gt;The cost is not measured in lines of code. It is measured in abandoned projects, wasted grant cycles, and formats that change without warning.&lt;&#x2F;p&gt;
&lt;p&gt;I should be precise about the risk. Thermo has not, to my knowledge, deliberately broken downstream tools by changing their DLL. The risk is subtler. Instrument firmware evolves. New scan modes, new detectors, new ion optics. The format tracks the hardware. When a new instrument ships, the format changes, and every downstream converter chases the update. That is not malice. It is the natural consequence of a closed format that the community cannot maintain independently.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-legal-uncertainty-is-itself-a-cost&quot;&gt;The legal uncertainty is itself a cost&lt;&#x2F;h2&gt;
&lt;p&gt;This is where the argument lands hardest for me.&lt;&#x2F;p&gt;
&lt;p&gt;I have an early-stage idea for reading Thermo .raw files natively. No RawFileReader. No .NET. No Windows. A single static binary that you copy to a cluster node and run.&lt;&#x2F;p&gt;
&lt;p&gt;I do not know if it is legal to try.&lt;&#x2F;p&gt;
&lt;p&gt;Reverse engineering for interoperability exists in a legal gray area that varies by jurisdiction
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;In the US, reverse engineering for interoperability may be protected as fair use under certain conditions (Sony v. Connectix, Sega v. Accolade). The EU explicitly permits reverse engineering to achieve interoperability under the Software Directive (2009&#x2F;24&#x2F;EC). Canadian law is less clear. In all cases, the specifics of how the reverse engineering is done and what license agreements govern the software matter enormously.
&lt;&#x2F;span&gt;
. Thermo&#x27;s RawFileReader license
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;RawFileReader ships with a proprietary license document. The license terms around reverse engineering, decompilation, and competitive use are standard for vendor SDKs but deliberately restrictive. The exact boundaries are unclear without legal review, which itself costs money most researchers do not have.
&lt;&#x2F;span&gt;
 restricts what you can do with their reader. Whether analyzing the format independently through clean-room reverse engineering is permitted depends on who you ask and where you are.&lt;&#x2F;p&gt;
&lt;p&gt;The uncertainty itself is a burden. A researcher who wants to build a better, faster, more open reader has to either accept legal risk or spend resources on legal review that could go to the actual work. The person most motivated to solve the problem is also the person with the least clarity on whether trying is allowed.&lt;&#x2F;p&gt;
&lt;p&gt;That is the structural failure in its purest form.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;mzml-is-not-the-whole-answer-and-that-is-fine&quot;&gt;mzML is not the whole answer, and that is fine&lt;&#x2F;h2&gt;
&lt;p&gt;mzML is the HUPO-PSI standard
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The Proteomics Standards Initiative unified mzXML and mzData into mzML in 2008. It has been the community interchange format for nearly two decades.
&lt;&#x2F;span&gt;
. It is XML-based, verbose, and designed for interoperability over compactness. It solved the problem of having a common format for processed data.&lt;&#x2F;p&gt;
&lt;p&gt;I am not arguing against mzML. I am arguing that open formats are bigger than mzML.&lt;&#x2F;p&gt;
&lt;p&gt;mzMLb exists
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Bhamber RS et al. &quot;mzMLb: A Future-Proof Raw Mass Spectrometry Data Format.&quot; &lt;em&gt;J. Proteome Res.&lt;&#x2F;em&gt; 20(1):172-183, 2021. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.0c00192&quot;&gt;DOI&lt;&#x2F;a&gt;. Reference implementation in ProteoWizard.
&lt;&#x2F;span&gt;
. It compresses spectra into HDF5 datasets while keeping metadata as XML. File sizes comparable to vendor formats. Reference implementation in ProteoWizard. The path forward exists. It needs adoption, not invention.&lt;&#x2F;p&gt;
&lt;p&gt;An open format can be binary, compressed, and optimized for random access. What makes it open is not the serialization choice. It is whether you can read it without asking permission. The specification is public. A reference reader exists that does not depend on proprietary libraries. The format does not change in breaking ways without notice.&lt;&#x2F;p&gt;
&lt;p&gt;mzML satisfies these. mzMLb improves on the performance dimension. The fight is not about XML versus binary. It is about the conversion layer between the instrument and the analysis.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-middleman-is-the-bottleneck&quot;&gt;The middleman is the bottleneck&lt;&#x2F;h2&gt;
&lt;p&gt;The problem is not that vendors have proprietary formats. Every instrument vendor has one. The problem is that accessing the data requires proprietary libraries controlled entirely by a single company.&lt;&#x2F;p&gt;
&lt;p&gt;The dependency chain is real. RawFileReader depends on Thermo&#x27;s DLLs. ThermoRawFileParser depends on RawFileReader. Every downstream pipeline depends on ThermoRawFileParser. A proprietary format with an open, maintained reader is workable. A proprietary format with no public specification and a proprietary reader gating all access is a structural failure.&lt;&#x2F;p&gt;
&lt;p&gt;The stronger argument is not about Thermo specifically. It is that the ecosystem should not depend on any single vendor&#x27;s reader implementation. That criticism applies equally to Thermo, Bruker, Waters, and Sciex.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-land&quot;&gt;Where I land&lt;&#x2F;h2&gt;
&lt;p&gt;Open formats tend to become dominant when interoperability creates enough economic value. In proteomics, the value is clear: pipelines that cross labs, instruments, and software stacks without a conversion tax. The transition takes time, and the wasted effort accumulates in the meantime.&lt;&#x2F;p&gt;
&lt;p&gt;Thermo is the most visible obstacle because they have the largest installed base and the most locked-down access model. They feel like the old guard that has not noticed the world changed. Bruker shows you can sell instruments, protect IP, and make your data more accessible. Neither is fully open, but the gap between them shows the range of possible choices, and Thermo&#x27;s position is a choice.&lt;&#x2F;p&gt;
&lt;p&gt;The software layer matters too. Spectronaut makes a speed argument for HTRMS. DIA-NN shows performance does not require a closed format. If your tool is fast and your format is open, you win on both axes. If your tool is fast and your format is closed, only one of those things ages well.&lt;&#x2F;p&gt;
&lt;p&gt;I will keep converting .raw files with ThermoRawFileParser like everyone else. It works. It solved the Linux problem, and I am grateful to the people who built it. But the next generation of proteomics tools should not start with a toll booth.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>ProteoForge: What Happens When You Stop Averaging Your Peptides</title>
        <published>2025-12-20T00:00:00+00:00</published>
        <updated>2025-12-20T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-proteoforge-deep-dive/"/>
        <id>https://eneskemalergin.github.io/blog/post-proteoforge-deep-dive/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-proteoforge-deep-dive/">&lt;p&gt;I spent the better part of my PhD staring at peptide intensity matrices. Thousands of rows, dozens of columns, lots of missing values. The standard workflow says: roll those peptides up into protein-level quantities, run your differential expression, make a volcano plot, write the paper
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;If you have never seen a peptide intensity matrix, imagine a spreadsheet where most of the cells in columns 5 through 12 are empty, and the ones that are not empty disagree with each other about what the protein is doing. That is bottom-up proteomics.
&lt;&#x2F;span&gt;
. I did that. Multiple times. It always felt like we were throwing away information.&lt;&#x2F;p&gt;
&lt;p&gt;This post is about ProteoForge, the framework that came out of that frustration. It is not a summary of the paper (you can &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.biorxiv.org&#x2F;content&#x2F;10.64898&#x2F;2025.12.12.694008v1&quot;&gt;read the preprint&lt;&#x2F;a&gt; for that). It is about the problem as I experienced it, the decisions we made while building the tool, and what surprised us when we finally applied it to real data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-averaging-problem&quot;&gt;The averaging problem&lt;&#x2F;h2&gt;
&lt;p&gt;The human genome codes for roughly 20,000 proteins. But the actual number of distinct protein forms in a cell is orders of magnitude larger&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-1-1&quot;&gt;&lt;a href=&quot;#fn-1&quot;&gt;1&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;. Alternative splicing, post-translational modifications, proteolytic processing: these create proteoforms&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-2-1&quot;&gt;&lt;a href=&quot;#fn-2&quot;&gt;2&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;, and proteoforms from the same gene can have opposing biological functions.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The KRAS example.&lt;&#x2F;strong&gt; Different proteoforms of the KRAS oncogene have opposing effects on MAPK signalling[^3]. KRAS4A and KRAS4B are splice variants with different membrane targeting, different interactomes, and different downstream effects. A protein-level average across both would hide the biology entirely. KRAS is not an edge case. It is the rule at scale.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Bottom-up proteomics measures peptides, not intact proteins. The field has lived with this trade-off for years: you get broad coverage but you lose proteoform identity. The standard fix is to group peptides by protein accession, average their intensities, and call it a protein quantity.&lt;&#x2F;p&gt;
&lt;p&gt;That averaging step is where the information dies.&lt;&#x2F;p&gt;
&lt;p&gt;If three peptides from a protein go up under treatment and two go down, the protein-level average might show no change at all. A flat line on your volcano plot. You move on. You never see that the protein was actually doing something complicated at the proteoform level.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-already-existed-and-where-it-broke&quot;&gt;What already existed (and where it broke)&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Feature&lt;&#x2F;th&gt;&lt;th&gt;PeCorA&lt;&#x2F;th&gt;&lt;th&gt;COPF&lt;&#x2F;th&gt;&lt;th&gt;ProteoForge&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Discordant peptide detection&lt;&#x2F;td&gt;&lt;td&gt;Yes&lt;&#x2F;td&gt;&lt;td&gt;No&lt;&#x2F;td&gt;&lt;td&gt;Yes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Peptide grouping into proteoforms&lt;&#x2F;td&gt;&lt;td&gt;No&lt;&#x2F;td&gt;&lt;td&gt;Yes&lt;&#x2F;td&gt;&lt;td&gt;Yes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Missing data tolerance&lt;&#x2F;td&gt;&lt;td&gt;Poor (degrades early)&lt;&#x2F;td&gt;&lt;td&gt;Poor (needs complete data)&lt;&#x2F;td&gt;&lt;td&gt;Good (stable to 60%)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Statistical model&lt;&#x2F;td&gt;&lt;td&gt;Per-peptide linear model&lt;&#x2F;td&gt;&lt;td&gt;Peptide correlation matrix&lt;&#x2F;td&gt;&lt;td&gt;RLM with interaction terms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Minimum requirement&lt;&#x2F;td&gt;&lt;td&gt;2+ treatment groups&lt;&#x2F;td&gt;&lt;td&gt;Many replicates, low missingness&lt;&#x2F;td&gt;&lt;td&gt;4+ peptides per protein&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The core issue with both tools, and really with every method we tested: missing data
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;A typical DIA dataset from an Orbitrap will have about 25% missing values at the precursor level. By the time you filter for valid peptides per protein, that number often exceeds 40%. Most statistical methods assume complete data and crash or bias badly when confronted with this.
&lt;&#x2F;span&gt;
. In a typical DIA proteomics experiment, 20 to 40 percent of peptide measurements are missing. Some are missing at random (instrument did not pick them up). Some are missing because the peptide is genuinely absent in that condition. The distinction matters enormously, and most tools either ignore it or require you to delete incomplete cases.&lt;&#x2F;p&gt;
&lt;p&gt;That was the gap.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-idea-behind-proteoforge&quot;&gt;The idea behind ProteoForge&lt;&#x2F;h2&gt;
&lt;p&gt;ProteoForge does four things, in order. I will walk through each one, not with equations (the paper has those), but with the reasoning.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;Figure 1: The four-module ProteoForge pipeline. (A) Framework schematic. (B) Effect of normalization steps on peptide distributions. (C) Three example proteins showing peptide profiles, distance heatmaps, and final dPF assignments.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;module-1-normalize-relative-to-a-control&quot;&gt;Module 1: Normalize relative to a control&lt;&#x2F;h3&gt;
&lt;p&gt;Raw peptide intensities are noisy and have different baselines across samples. We log-transform, z-score, and then subtract the mean control intensity for each peptide. After this step, every value represents the deviation of that peptide from its own control baseline. Sounds simple. It is. But it makes the downstream models much cleaner because you are comparing changes, not absolute values.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;See Figure 1B.&lt;&#x2F;strong&gt; The control-adjusted values collapse the four condition-specific distributions into a shared zero-centered space. This is the input for everything that follows.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;module-2-find-discordant-peptides&quot;&gt;Module 2: Find discordant peptides&lt;&#x2F;h3&gt;
&lt;p&gt;This is the statistical core. For each protein, we fit a linear model that asks: does this peptide behave differently from its siblings across conditions?&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;r&quot;&gt;R&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-r&quot;&gt;Intensity ~ Condition * Peptide&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: The interaction model used by ProteoForge for each protein.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;The interaction term (&lt;code&gt;Condition * Peptide&lt;&#x2F;code&gt;) captures the discordance. If a peptide responds to the condition differently than the other peptides from the same protein, the interaction term will be significant.&lt;&#x2F;p&gt;
&lt;p&gt;We tested several model types. Ordinary least squares, weighted least squares with custom imputation-aware weights, generalized linear models, and robust linear models (RLM) with Huber M-estimation&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-6-1&quot;&gt;&lt;a href=&quot;#fn-6&quot;&gt;3&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;Why we chose RLM over other models&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;Supplementary Note 1 in the paper contains the full model comparison across OLS, WLS with custom weights, GLM, and RLM. RLM with Huber weights gave the best balance between performance and ease of use. The custom-weighted WLS models can outperform RLM at extreme missingness (above 60%), but they require you to correctly specify the weight matrix, which is error-prone in practice.&lt;&#x2F;p&gt;
&lt;p&gt;At moderate missingness (under 50%), all models performed similarly. The differences emerged at the edges: high missingness, many imputed values, unbalanced group sizes. RLM handled all of these without requiring the user to configure anything beyond the default.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;p&gt;We settled on RLM as the default because of a property that turned out to be exactly what we needed: it automatically down-weights outliers. Imputed values, especially the down-shifted low values used for condition-complete missingness, look like outliers to the model. RLM treats them accordingly. No manual weight specification needed. The user does not have to think about which values were imputed and which were real.&lt;&#x2F;p&gt;
&lt;p&gt;The p-values from the interaction terms go through a two-step Benjamini-Hochberg FDR correction&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-7-1&quot;&gt;&lt;a href=&quot;#fn-7&quot;&gt;4&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;: first within each protein, then globally
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The first correction accounts for multiple peptides per protein. The second accounts for multiple proteins genome-wide. A miscleaved peptide or technical artifact would have to survive both rounds, which is why we use this approach despite it being conservative.
&lt;&#x2F;span&gt;
. Peptides that pass the threshold (we used adjusted p &amp;lt; 0.001) are flagged as significantly discordant.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;module-3-cluster-the-discordant-peptides&quot;&gt;Module 3: Cluster the discordant peptides&lt;&#x2F;h3&gt;
&lt;p&gt;Finding discordant peptides is not enough. You need to know which discordant peptides move together, because a proteoform is defined by a group of co-varying peptides, not a single one.&lt;&#x2F;p&gt;
&lt;p&gt;We compute the Euclidean distance between median adjusted intensity profiles and cluster with Ward linkage&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-8-1&quot;&gt;&lt;a href=&quot;#fn-8&quot;&gt;5&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;. The default cut method (&lt;code&gt;hybrid_outlier_cut&lt;&#x2F;code&gt;) determines the number of clusters automatically.&lt;&#x2F;p&gt;
&lt;p&gt;This is the part that separates ProteoForge from PeCorA. PeCorA stops at &quot;this peptide is different.&quot; ProteoForge goes further: &quot;these three peptides are different in the same way, and they likely belong to the same proteoform.&quot;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;module-4-build-proteoforms&quot;&gt;Module 4: Build proteoforms&lt;&#x2F;h3&gt;
&lt;p&gt;The naming convention tells the story:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Label&lt;&#x2F;th&gt;&lt;th&gt;Meaning&lt;&#x2F;th&gt;&lt;th&gt;Composition&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;dPF_0&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Canonical proteoform&lt;&#x2F;td&gt;&lt;td&gt;All non-discordant peptides. Your &quot;clean&quot; protein signal.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;dPF_1&lt;&#x2F;code&gt;, &lt;code&gt;dPF_2&lt;&#x2F;code&gt;, ...&lt;&#x2F;td&gt;&lt;td&gt;Differential proteoforms&lt;&#x2F;td&gt;&lt;td&gt;Clusters with 2+ peptides and 1+ significantly discordant.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;dPF_-1&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Singleton PTM flag&lt;&#x2F;td&gt;&lt;td&gt;Significantly discordant peptides in singleton clusters. Likely single-site modifications.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;See Figure 1C.&lt;&#x2F;strong&gt; Three example proteins with their peptide profiles, distance heatmaps, and final dPF assignments. Protein 2 is the interesting one: three peptides cluster into a dPF while a fourth ends up as a singleton PTM.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The result is a peptide-to-dPF mapping table. Each protein can have zero, one, or multiple differential proteoforms, plus a canonical form.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;benchmarking-what-we-actually-learned&quot;&gt;Benchmarking: what we actually learned&lt;&#x2F;h2&gt;
&lt;p&gt;Benchmarking a proteoform discovery tool is tricky because there is no ground truth for proteoforms in most real datasets. We used two approaches: real data with in-silico perturbations (the COPF benchmark approach, using a SWATH-MS interlab HEK293 dataset&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-4-1&quot;&gt;&lt;a href=&quot;#fn-4&quot;&gt;6&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-5-1&quot;&gt;&lt;a href=&quot;#fn-5&quot;&gt;7&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;), and controlled simulations.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;Figure 2: Benchmark results across real and simulated data. (A) SWATH-MS interlab test. (B-E) Simulations 1-4: imputation impact, missingness tolerance, signal strength, multi-condition complexity.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Four simulation scenarios tested the things that matter in practice:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;&#x2F;th&gt;&lt;th&gt;What was varied&lt;&#x2F;th&gt;&lt;th&gt;ProteoForge&lt;&#x2F;th&gt;&lt;th&gt;PeCorA&lt;&#x2F;th&gt;&lt;th&gt;Takeaway&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1. Imputation impact&lt;&#x2F;td&gt;&lt;td&gt;Up to 35% data removed, imputed&lt;&#x2F;td&gt;&lt;td&gt;MCC dropped 0.005&lt;&#x2F;td&gt;&lt;td&gt;MCC dropped 0.158&lt;&#x2F;td&gt;&lt;td&gt;30x less sensitive to imputation&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;2. Missingness tolerance&lt;&#x2F;td&gt;&lt;td&gt;0% to 80% missing values&lt;&#x2F;td&gt;&lt;td&gt;Stable MCC 0.46-0.57 to 60%&lt;&#x2F;td&gt;&lt;td&gt;Degraded sharply from start&lt;&#x2F;td&gt;&lt;td&gt;Hard cliff at 60% for ProteoForge&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3. Signal strength&lt;&#x2F;td&gt;&lt;td&gt;Fold change from low to high&lt;&#x2F;td&gt;&lt;td&gt;0.91 MCC at high FC&lt;&#x2F;td&gt;&lt;td&gt;0.52 MCC at high FC&lt;&#x2F;td&gt;&lt;td&gt;Nearly 2x better at strong signal&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;4. Multi-condition&lt;&#x2F;td&gt;&lt;td&gt;2 to 6 conditions&lt;&#x2F;td&gt;&lt;td&gt;Flat performance&lt;&#x2F;td&gt;&lt;td&gt;Variable&lt;&#x2F;td&gt;&lt;td&gt;Scales to complex designs&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The key visual is Figure 2C.&lt;&#x2F;strong&gt; ProteoForge&#x27;s MCC line stays flat across increasing missingness while PeCorA&#x27;s drops linearly. Panels A through E map to the SWATH-MS interlab test and simulations 1 through 4 respectively.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;For peptide grouping (which PeCorA cannot do), ProteoForge consistently hit MCC around 0.45 to 0.61, while COPF hovered around 0.27. At a p-value cutoff of 0.001, COPF&#x27;s grouping MCC was near zero in most simulated conditions.&lt;&#x2F;p&gt;
&lt;p&gt;One result surprised us. In the imputation simulation, ProteoForge&#x27;s &lt;em&gt;grouping&lt;&#x2F;em&gt; performance actually improved slightly after imputation (MCC from 0.60 to 0.61). Why? When a peptide is completely missing in one condition and gets imputed with a down-shifted low value, it creates a strong, unambiguous signal. The clustering picks it up correctly.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;What about AlphaQuant and MSstatsWeightedSummary?&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;Two other tools address related problems in this space. AlphaQuant uses tree-based quantification to organize peptide data hierarchically and can infer proteoform groups from bottom-up data. MSstatsWeightedSummary handles the shared peptide problem through weighted summarization with Huber loss. Both are complementary to ProteoForge rather than direct competitors. ProteoForge focuses specifically on the interaction between missing data, discordant peptide detection, and proteoform grouping. The three tools answer different questions and could be used in combination on the same dataset.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;h2 id=&quot;the-hypoxia-application&quot;&gt;The hypoxia application&lt;&#x2F;h2&gt;
&lt;p&gt;Benchmarks are one thing. Real biology is another.&lt;&#x2F;p&gt;
&lt;p&gt;We applied ProteoForge to a published dataset from Tomin et al. (2025)&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-9-1&quot;&gt;&lt;a href=&quot;#fn-9&quot;&gt;8&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;, who measured the proteome of H358 lung cancer cells under normoxia (21% O2) and hypoxia (1% O2) at 48 and 72 hours. The data came from DIA-NN&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-10-1&quot;&gt;&lt;a href=&quot;#fn-10&quot;&gt;9&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt; as precursor-level quantities. After filtering and imputation, we had 95,369 peptides across 7,161 proteins.&lt;&#x2F;p&gt;
&lt;p&gt;The numbers alone tell a story. At 48 hours, about 15% of proteins had multiple discordant peptides. At 72 hours, that jumped to 36%. Hypoxia is not a subtle perturbation, and the proteoform-level response grows over time in a way that protein-level analysis completely misses. &lt;strong&gt;Figure 3A&lt;&#x2F;strong&gt; shows this distribution shift from 48 to 72 hours. The fraction of proteins with multiple significantly discordant peptides more than doubles.&lt;&#x2F;p&gt;
&lt;p&gt;The original study by Tomin et al.&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-9-2&quot;&gt;&lt;a href=&quot;#fn-9&quot;&gt;8&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt; reported increased levels of S-lactoylglutathione (a GLO1 product) under hypoxia, but no change in GLO1 protein abundance. That is a contradiction. If the product goes up, something about the enzyme should be different.&lt;&#x2F;p&gt;
&lt;p&gt;ProteoForge resolved it.&lt;&#x2F;p&gt;
&lt;p&gt;At 48 hours, one peptide in GLO1 (harboring a known phosphorylation site at T35 and a mutagenesis site at Q34) showed elevated intensity under hypoxia while the other peptides stayed flat. By 72 hours, this expanded into a multi-peptide dPF (three peptides, including one with ubiquitination&#x2F;acetylation at K148). The protein average saw nothing because the canonical peptides diluted the signal.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Figures 3D and 3E&lt;&#x2F;strong&gt; show the GLO1 story in full. 3D has the peptide intensity profiles across conditions. 3E maps each peptide against known PTMs and protein features. The phosphorylation at T35 is particularly interesting: it sits near GLO1&#x27;s active site, and the discordance emerges before the multi-peptide dPF forms at 72 hours.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;This is what I mean by &quot;the averaging problem.&quot; GLO1 was regulated. The original analysis just could not see it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;fpgt-catching-isoform-driven-variance&quot;&gt;FPGT: catching isoform-driven variance&lt;&#x2F;h3&gt;
&lt;p&gt;Fucose-1-phosphate guanylyltransferase (FPGT) provided a different kind of example. At 48 hours, all peptides behaved the same. At 72 hours, two peptides spanning amino acids 60 to 95 dropped under hypoxia while the rest stayed stable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Figures 3F and 3G&lt;&#x2F;strong&gt; show the FPGT peptide profiles and protein schematic, with the two discordant peptides highlighted in the N-terminal region and isoform boundaries marked.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-happens-to-your-pathway-analysis&quot;&gt;What happens to your pathway analysis&lt;&#x2F;h3&gt;
&lt;p&gt;This was, for me, the most striking result.&lt;&#x2F;p&gt;
&lt;p&gt;We ran four different protein-level quantification strategies through QuEStVar&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-11-1&quot;&gt;&lt;a href=&quot;#fn-11&quot;&gt;10&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt; (our equivalence testing framework from the earlier paper) and then into pathway enrichment with g:Profiler&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-12-1&quot;&gt;&lt;a href=&quot;#fn-12&quot;&gt;11&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Quantification strategy&lt;&#x2F;th&gt;&lt;th&gt;% Proteins regulated at 72h&lt;&#x2F;th&gt;&lt;th&gt;GO:BP terms found&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DIANN Original&lt;&#x2F;td&gt;&lt;td&gt;2%&lt;&#x2F;td&gt;&lt;td&gt;Near zero&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Mean (top 3 peptides)&lt;&#x2F;td&gt;&lt;td&gt;~3%&lt;&#x2F;td&gt;&lt;td&gt;Minimal&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Mean (all peptides)&lt;&#x2F;td&gt;&lt;td&gt;~4%&lt;&#x2F;td&gt;&lt;td&gt;Minimal&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;ProteoForge dPFs&lt;&#x2F;td&gt;&lt;td&gt;23%&lt;&#x2F;td&gt;&lt;td&gt;87 elevated + 181 reduced&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;That is not a marginal improvement. That is the difference between &quot;hypoxia did almost nothing to this proteome&quot; and &quot;hypoxia restructured a quarter of the proteome at the proteoform level.&quot;&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Figures 4C and 5 tell this story visually.&lt;&#x2F;strong&gt; Figure 4C shows the stacked bar charts of regulatory classifications. The contrast between &quot;Original&quot; and &quot;dPFs&quot; bars is startling. Figure 5 shows the downstream pathway enrichment: &quot;Original&quot; finds almost no elevated or reduced GO terms at 72 hours while &quot;dPFs&quot; finds hundreds, including canonical hypoxia response pathways.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The pathway results were telling. The original analysis found broad equivalence across GO categories at 72 hours. The dPF analysis found canonical hypoxia response pathways correctly classified as elevated. Cell cycle terms were reduced. Metabolic terms showed complex, time-dependent shifts. Signaling terms appeared elevated.&lt;&#x2F;p&gt;
&lt;p&gt;The &quot;Original&quot; analysis missed all of this. Not because the data was bad. Because the quantification averaged it away.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;things-i-wish-i-had-known-earlier&quot;&gt;Things I wish I had known earlier&lt;&#x2F;h2&gt;
&lt;p&gt;A few practical notes for anyone thinking about using ProteoForge.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-warning&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;⚠️&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;The 60% missingness cliff.&lt;&#x2F;strong&gt; ProteoForge handles missing data better than anything else we tested, but there is a hard limit around 60%. Past that, RLM starts treating imputed values as the majority signal rather than outliers, and performance drops sharply. If your dataset has more than 60% missing values, consider using the WLS model with custom imputation weights instead of RLM (see Supplementary Note 1). Or consider whether the dataset is salvageable at all.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;

&lt;div class=&quot;callout callout-warning&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;⚠️&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Minimum four peptides per protein.&lt;&#x2F;strong&gt; ProteoForge requires at least four peptides per protein. This is not arbitrary. The interaction model needs enough peptides to estimate the protein-level consensus and detect deviations from it. Proteins with fewer peptides are excluded. This means ProteoForge is best suited for DIA or deep DDA datasets. Shallow experiments with 1 to 2 peptides per protein will not benefit.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;&lt;strong&gt;It is not a package yet.&lt;&#x2F;strong&gt; The analysis repository on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;LangeLab&#x2F;ProteoForge_Analysis&quot;&gt;GitHub&lt;&#x2F;a&gt; contains the scripts and notebooks used for the manuscript. A standalone Python package is under development but not released. If you use the current code, expect some rough edges.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters-to-me&quot;&gt;Why this matters (to me)&lt;&#x2F;h2&gt;
&lt;p&gt;I started this work because of pediatric cancer. My PhD focused on computational proteomics for childhood leukemia, and the proteoform question kept coming up. In our leukemia data, we would see proteins where the overall level looked stable between diagnosis and relapse, but specific peptides shifted dramatically. We could not make sense of it with protein-level tools.&lt;&#x2F;p&gt;
&lt;p&gt;ProteoForge came from that frustration. The hypoxia application in the paper is a clean, well-controlled dataset that made for a good proof of concept. But the motivation was always translational: can we find proteoform-level changes in disease that protein-level analysis misses?&lt;&#x2F;p&gt;
&lt;p&gt;The GLO1 result gives me confidence that the answer is yes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;&#x2F;h2&gt;
&lt;p&gt;The preprint is currently under review at the &lt;em&gt;Journal of Proteome Research&lt;&#x2F;em&gt;. The standalone Python package is in development.&lt;&#x2F;p&gt;
&lt;p&gt;If you want to try it on your data, the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;LangeLab&#x2F;ProteoForge_Analysis&quot;&gt;analysis repository&lt;&#x2F;a&gt; has everything. The Zenodo snapshot (&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.5281&#x2F;zenodo.17795845&quot;&gt;doi:10.5281&#x2F;zenodo.17795845&lt;&#x2F;a&gt;) freezes the exact version used in the manuscript.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;strong&gt;Paper:&lt;&#x2F;strong&gt; Ergin, E. K., Conrrero, A., Ferguson, K. M., Lange, P. F. (2025). ProteoForge: An Imputation-Aware Framework for Differential Proteoform Discovery in Bottom-Up Proteomics. &lt;em&gt;bioRxiv&lt;&#x2F;em&gt;. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.biorxiv.org&#x2F;content&#x2F;10.64898&#x2F;2025.12.12.694008v1&quot;&gt;doi:10.64898&#x2F;2025.12.12.694008&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Code:&lt;&#x2F;strong&gt; &lt;a rel=&quot;external&quot; href=&quot;http:&#x2F;&#x2F;github.com&#x2F;LangeLab&#x2F;ProteoForge_Analysis&quot;&gt;github.com&#x2F;LangeLab&#x2F;ProteoForge_Analysis&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Data:&lt;&#x2F;strong&gt; PRIDE repository, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.ebi.ac.uk&#x2F;pride&#x2F;archive&#x2F;projects&#x2F;PXD062503&quot;&gt;PXD062503&lt;&#x2F;a&gt; (Tomin et al. hypoxia dataset)&lt;&#x2F;p&gt;
&lt;section class=&quot;footnotes&quot;&gt;
&lt;ol class=&quot;footnotes-list&quot;&gt;
&lt;li id=&quot;fn-1&quot;&gt;
&lt;p&gt;Smith, L.M. et al. (2021). The Human Proteoform Project: Defining the human proteome. &lt;em&gt;Science Advances&lt;&#x2F;em&gt;, 7(46). &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1126&#x2F;sciadv.abk0734&quot;&gt;doi:10.1126&#x2F;sciadv.abk0734&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-1-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-2&quot;&gt;
&lt;p&gt;Smith, L.M. &amp;amp; Kelleher, N.L.; Consortium for Top Down Proteomics (2013). Proteoform: a single term describing protein complexity. &lt;em&gt;Nature Methods&lt;&#x2F;em&gt;, 10(3), 186-187. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1038&#x2F;nmeth.2369&quot;&gt;doi:10.1038&#x2F;nmeth.2369&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-2-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-6&quot;&gt;
&lt;p&gt;Huber, P.J. (1964). Robust Estimation of a Location Parameter. &lt;em&gt;Annals of Mathematical Statistics&lt;&#x2F;em&gt;, 35(1), 73-101. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1214&#x2F;aoms&#x2F;1177703732&quot;&gt;doi:10.1214&#x2F;aoms&#x2F;1177703732&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-6-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-7&quot;&gt;
&lt;p&gt;Benjamini, Y. &amp;amp; Hochberg, Y. (1995). Controlling the False Discovery Rate: A Practical and Powerful Approach to Multiple Testing. &lt;em&gt;Journal of the Royal Statistical Society: Series B&lt;&#x2F;em&gt;, 57(1), 289-300. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1111&#x2F;j.2517-6161.1995.tb02031.x&quot;&gt;doi:10.1111&#x2F;j.2517-6161.1995.tb02031.x&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-7-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-8&quot;&gt;
&lt;p&gt;Ward, J.H. Jr. (1963). Hierarchical Grouping to Optimize an Objective Function. &lt;em&gt;Journal of the American Statistical Association&lt;&#x2F;em&gt;, 58(301), 236-244. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1080&#x2F;01621459.1963.10500845&quot;&gt;doi:10.1080&#x2F;01621459.1963.10500845&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-8-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-4&quot;&gt;
&lt;p&gt;Peptide Correlation Analysis (PeCorA) Reveals Differential Proteoform Regulation (2021). &lt;em&gt;Journal of Proteome Research&lt;&#x2F;em&gt;, 20(4). &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.0c00602&quot;&gt;doi:10.1021&#x2F;acs.jproteome.0c00602&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-4-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-5&quot;&gt;
&lt;p&gt;Bludau, I. et al. (2021). Systematic detection of functional proteoform groups from bottom-up proteomic datasets. &lt;em&gt;Nature Communications&lt;&#x2F;em&gt;, 12, 3810. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1038&#x2F;s41467-021-24030-x&quot;&gt;doi:10.1038&#x2F;s41467-021-24030-x&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-5-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-9&quot;&gt;
&lt;p&gt;Tomin, T. et al. (2025). Increased antioxidative defense and reduced advanced glycation end-product formation by metabolic adaptation in non-small-cell-lung-cancer patients. &lt;em&gt;Nature Communications&lt;&#x2F;em&gt;. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1038&#x2F;s41467-025-60326-y&quot;&gt;doi:10.1038&#x2F;s41467-025-60326-y&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-9-1&quot;&gt;↩&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-9-2&quot;&gt;↩2&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-10&quot;&gt;
&lt;p&gt;Demichev, V. et al. (2020). DIA-NN: neural networks and interference correction enable deep proteome coverage in high throughput. &lt;em&gt;Nature Methods&lt;&#x2F;em&gt;, 17, 41-44. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1038&#x2F;s41592-019-0638-x&quot;&gt;doi:10.1038&#x2F;s41592-019-0638-x&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-10-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-11&quot;&gt;
&lt;p&gt;Ergin, E.K., Myung, J.J.K., Lange, P.F. (2024). Statistical Testing for Protein Equivalence Identifies Core Functional Modules Conserved across 360 Cancer Cell Lines. &lt;em&gt;Journal of Proteome Research&lt;&#x2F;em&gt;, 23(6), 2169-2185. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1021&#x2F;acs.jproteome.4c00131&quot;&gt;doi:10.1021&#x2F;acs.jproteome.4c00131&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-11-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-12&quot;&gt;
&lt;p&gt;Raudvere, U. et al. (2019). g:Profiler: a web server for functional enrichment analysis and conversions of gene lists (2019 update). &lt;em&gt;Nucleic Acids Research&lt;&#x2F;em&gt;, 47(W1), W191-W198. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1093&#x2F;nar&#x2F;gkz369&quot;&gt;doi:10.1093&#x2F;nar&#x2F;gkz369&lt;&#x2F;a&gt; &lt;a href=&quot;#fr-12-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;&#x2F;section&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Workflow Engines and the Case for a Zig-Based One</title>
        <published>2025-10-29T00:00:00+00:00</published>
        <updated>2025-10-29T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/opinion-workflow-engine-in-zig/"/>
        <id>https://eneskemalergin.github.io/blog/opinion-workflow-engine-in-zig/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/opinion-workflow-engine-in-zig/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; I have used Snakemake, Nextflow, and GNU Make to run real proteomics pipelines. All three solve the big problem. Reproducible execution graphs. And all three make the small things harder than they should be. Container overhead, JVM startup, and general-purpose design add friction that a domain-specific engine could remove. This is not a plan. It is a direction I am curious about.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;I keep coming back to an idea I have not built.&lt;&#x2F;p&gt;
&lt;p&gt;A workflow engine for bioinformatics. No runtime. No JVM. No Conda environments to maintain. A single static binary that knows what mzML and FASTA are, because those are the files we actually work with. Copy it to a cluster node and run it.&lt;&#x2F;p&gt;
&lt;p&gt;I know how that sounds. The space already has Nextflow, Snakemake, CWL, WDL
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;And Cromwell, Toil, Luigi, Prefect, Airflow, Parsl. The design space is wider than the bioinformatics corner. I name the ones I have used.
&lt;&#x2F;span&gt;
. They all have real users and real papers. Adding one more sounds like hubris.&lt;&#x2F;p&gt;
&lt;p&gt;The idea will not leave me alone. I want to explain why, and where it is probably wrong.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tools-i-actually-use&quot;&gt;The tools I actually use&lt;&#x2F;h2&gt;
&lt;p&gt;My first workflow engine was a Makefile. It resolved dependencies, skipped completed steps, and parallelized across cores. For a single-machine analysis with a few dozen samples, it was enough.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;make&quot;&gt;Make&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-make&quot;&gt;results&#x2F;%.mzML: raw&#x2F;%.raw
    wine msconvert $&lt; --mzML -o $@

results&#x2F;%.pep.xml: results&#x2F;%.mzML
    tide-search $&lt; $@

results&#x2F;%.tsv: results&#x2F;%.pep.xml
    percolator $&lt; $@&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: What most of my workflows actually look like.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Then the data grew. I moved to Snakemake because it understood glob patterns and could distribute to a cluster. I moved to Nextflow because the lab standardized on it.&lt;&#x2F;p&gt;
&lt;p&gt;Both are good. Both frustrate me.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;snakemake-great-rules-heavy-environments&quot;&gt;Snakemake: great rules, heavy environments&lt;&#x2F;h2&gt;
&lt;p&gt;Snakemake is the most natural fit for a Python-native bioinformatician. Rules extend Python syntax. The DAG is built before execution so you see what will run before it starts. I liked using it.&lt;&#x2F;p&gt;
&lt;p&gt;The pain is the environment layer. Snakemake&#x27;s &lt;code&gt;conda:&lt;&#x2F;code&gt; directive pins environments per rule. For a pipeline with twenty steps, that is twenty environments to maintain. When one Conda solve hangs or a container pulls the wrong tag, you spend an afternoon fixing infrastructure. The Python runtime is not the problem. What sits on top of it is.&lt;&#x2F;p&gt;
&lt;p&gt;I also wonder if per-rule environments are solving the wrong problem. My pipelines depend on maybe four tools plus Python. Twenty Conda environments means I have managed the same numpy install twenty times.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;nextflow-channels-are-good-startup-is-not&quot;&gt;Nextflow: channels are good, startup is not&lt;&#x2F;h2&gt;
&lt;p&gt;Nextflow&#x27;s channel model is genuinely good
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;DSL2 channels handle per-sample branching in a way that Snakemake&#x27;s rule-level scoping does not. When a pipeline step decides what to run next based on the previous output, that matters.
&lt;&#x2F;span&gt;
. Data flows asynchronously. You branch, merge, and filter without writing explicit orchestration code. For hundreds of samples with per-sample variability, this model makes hard things manageable.&lt;&#x2F;p&gt;
&lt;p&gt;The startup cost is what I notice most. Even with &lt;code&gt;NXF_JVM_ARGS&lt;&#x2F;code&gt; tuned, there is a visible delay before the pipeline begins doing useful work. The JVM heap overhead
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The head process uses hundreds of megabytes. For pipelines running massive tools like MSFragger or DIA-NN, that overhead is negligible. The stronger problem is startup latency and the extra layer of abstraction when debugging.
&lt;&#x2F;span&gt;
 is rarely the dominant resource cost in pipelines where the tools themselves use tens of gigabytes.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I have debugged a Nextflow pipeline at 2 AM. I do not recommend it.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Here is where I go back and forth. The channel model solves a real problem. The startup latency and debugging friction are taxes. Are they coupled? Nextflow does not need the JVM for the channel model. It needs the JVM because it is written in Groovy. That is a historical choice, not a technical necessity.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-container-tax&quot;&gt;The container tax&lt;&#x2F;h2&gt;
&lt;p&gt;Both engines lean on containers for reproducibility. The idea is sound. Modern HPC clusters cache container layers effectively
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Apptainer and Singularity make this viable: pull once, run hundreds of jobs from the cached copy. Container startup drops dramatically after the first pull.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;The friction I feel is less about startup and more about maintenance. Image registries go down. Dependency rebuilds break cached layers. Provenance across container versions requires discipline that a shared cluster rarely enforces. The problem is not &quot;containers are slow.&quot; It is &quot;containers add a maintenance surface between me and the science.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Containers exist because shared environments drift. That is a real problem and I do not have a better answer. I want reproducibility without managing a container registry on every invocation. I do not know if that tension resolves.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-bioinformatics-workflows-actually-need&quot;&gt;What bioinformatics workflows actually need&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the honest truth about where the time goes. In most proteomics pipelines, roughly 95% of wall-clock time is in search and quantification tools, 4% is data movement, and 1% is orchestration
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Even eliminating all JVM and container overhead cuts the 1%, not the 95%. Speed is not the argument for a different engine. The argument is about deployment, debugging, and cognitive load.
&lt;&#x2F;span&gt;
. Eliminating JVM and container overhead changes the total runtime by barely a rounding error.&lt;&#x2F;p&gt;
&lt;p&gt;Speed is not the argument. The argument is that the orchestration layer should not add cognitive overhead that exceeds its runtime contribution. A fast, simple engine that I can understand and debug without a specialized DSL is worth having even if the pipeline runs the same wall-clock time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-would-a-format-aware-engine-actually-do&quot;&gt;What would a format-aware engine actually do?&lt;&#x2F;h2&gt;
&lt;p&gt;This is the question the idea keeps dodging. What does it mean for an engine to know mzML and FASTA?&lt;&#x2F;p&gt;
&lt;p&gt;It means the engine can validate inputs before the pipeline starts. A malformed mzML file is caught before it reaches the search tool, not during the fifth hour of a search run. It means the engine can extract metadata (instrument type, precursor tolerance, sample annotations) and propagate it through the DAG without manual wiring. It means the engine can construct parts of the pipeline graph automatically: if the input is mzML and the tool expects FASTA, the engine knows to insert a conversion step.&lt;&#x2F;p&gt;
&lt;p&gt;Current engines treat file formats as opaque strings. That is correct for general-purpose tools. For a domain-specific engine, format awareness means the engine understands the data, not just the file paths.&lt;&#x2F;p&gt;
&lt;p&gt;I do not know how far this stretches. It might be a small advantage that does not justify a new engine. It might open pipeline patterns that are awkward in current systems. This is where the novelty lives, not in the language choice.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-zig-keeps-coming-up&quot;&gt;Why Zig keeps coming up&lt;&#x2F;h2&gt;
&lt;p&gt;I should say this plainly: the language is not the hard part of a workflow engine. The hard parts are error recovery, checkpointing, cloud integration, and provenance tracking. C would work. Rust would work. Go works well and already has excellent concurrency and networking for orchestration workloads
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Go is understated in most Zig comparisons. It has mature networking, stable concurrency, large ecosystem, and cross-compiles to static binaries. Many workflow tools are written in Go for good reason. Zig&#x27;s advantage over Go for this problem is C interop, and even that matters less if the engine mostly orchestrates external processes.
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;What keeps me coming back to Zig is the combination of properties.&lt;&#x2F;p&gt;
&lt;p&gt;Zig produces a single static binary with no runtime. Deploy the engine with a file copy. No Python environment. No JVM. No Conda.&lt;&#x2F;p&gt;
&lt;p&gt;Zig calls C directly
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;code&gt;@cImport&lt;&#x2F;code&gt; on a C header and the functions are available. No binding layer, no FFI ceremony. ProteoWizard is C++ (needs a C ABI wrapper), but zlib, netCDF, and most compression libraries are straight C.
&lt;&#x2F;span&gt;
. For reading mzML through existing C libraries and compressing output, that integration path is short.&lt;&#x2F;p&gt;
&lt;p&gt;Zig has explicit memory. No garbage collector. For a long-running pipeline on a shared cluster node, predictable memory behavior matters more than peak throughput.&lt;&#x2F;p&gt;
&lt;p&gt;These are not unique to Zig. The difference is C interop. Rust needs &lt;code&gt;bindgen&lt;&#x2F;code&gt; and FFI safety wrappers. Go needs cgo. Both work, both add ceremony. For a tool that wraps other tools, ceremony compounds.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-have-not-figured-out&quot;&gt;What I have not figured out&lt;&#x2F;h2&gt;
&lt;p&gt;I have not built this. The idea is still a question mark.&lt;&#x2F;p&gt;
&lt;p&gt;Zig is pre-1.0
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;This is the biggest practical concern. A workflow engine is infrastructure software. Infrastructure values stability, ecosystem, and backwards compatibility more than language elegance. Starting an engine on a pre-1.0 language means accepting that every release may break the build.
&lt;&#x2F;span&gt;
. It will break my code between releases. The ecosystem is tiny compared to anything in the workflow space. If the engine needs a feature that Nextflow already has, the gap is not weeks of work. It is the accumulated years of edge cases that existing engines have already absorbed.&lt;&#x2F;p&gt;
&lt;p&gt;I also have not settled the most basic question: should this be a new engine, or a library, or a set of Zig tools that slot into an existing engine? A library that generates Makefiles solves deployment without solving the DAG problem. A Zig plugin for Nextflow gives format awareness without the JVM, if that is even possible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-land&quot;&gt;Where I land&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the honest version. A production workflow engine to replace Nextflow is probably a bad idea. The existing tools have absorbed too many edge cases. Even if I got the architecture right, the gap in real-world testing would take years to close.&lt;&#x2F;p&gt;
&lt;p&gt;But building a small one, not to ship but to understand? That is worth doing. The value is not the engine. It is the understanding of what makes workflow orchestration hard, what tradeoffs are real, and where current tools make choices that a domain-specific approach could avoid.&lt;&#x2F;p&gt;
&lt;p&gt;I will keep using Nextflow and Snakemake for production work. They work. They have communities. They ship reproducible pipelines that produce real results.&lt;&#x2F;p&gt;
&lt;p&gt;And I will probably build something small in this direction. Not to replace Nextflow. To understand the problem space well enough to know whether the idea has anything in it.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Why I&#x27;m Learning Zig Instead of Doubling Down on Rust</title>
        <published>2025-05-25T00:00:00+00:00</published>
        <updated>2025-05-25T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/opinion-zig/"/>
        <id>https://eneskemalergin.github.io/blog/opinion-zig/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/opinion-zig/">
&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;TLDR:&lt;&#x2F;strong&gt; Zig excels at the boring foundation layer. Small parsers, validators, indexers. Tools with no runtime, explicit memory, direct C interop, and a single static binary. Not replacing Rust for serious projects. Just occupying a different niche.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;how-i-got-here&quot;&gt;How I got here&lt;&#x2F;h2&gt;
&lt;p&gt;I defended my PhD in April 2025. I took a month and a half off, after it I will start as a staff bioinformatician in the same lab where I did my PhD. Same place, new role, to continue excited projects that we still haven’t finalized.&lt;&#x2F;p&gt;
&lt;p&gt;I care a lot about performance. I am the kind of person who profiles code that already runs fine, just to see if it can run faster. In academia the most important part is if the logic is sound and output is valid, the speed, memory, overall resource usage comes later. Unless you are not working with extremely large data or in a pipeline where every resource usage counts, optimizing things is secondary. Well, I spent my time with making my tools optimized anyway. Likely gotten my PhD 1-2 years longer than it should be. Slow code bothers me more than it probably should.&lt;&#x2F;p&gt;
&lt;p&gt;I already knew Rust. I had used it a bit, mostly small binders to speed up slow Python loops. It worked well and I respected it, I’ve definetly seen how it can actually modern toolkit for performant and safe language.&lt;&#x2F;p&gt;
&lt;p&gt;During my time off apart from gaming and enjoying my time off and out of the high that I finished my PhD, I’ve learned about Zig’s existence. It did peak my interest, it was relatively less known with a very particular philosophy that resonated with me.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-caught-me&quot;&gt;What caught me&lt;&#x2F;h2&gt;
&lt;p&gt;I am not going to list version numbers or changelogs. The Zig website covers all of that. What I liked was how the language works day to day. Also will mention Rust a lot since there are obvious similarities and there are big differences to mention…&lt;&#x2F;p&gt;
&lt;p&gt;Zig is explicit. There is no hidden control flow, and nothing allocates memory unless I ask it to. When I read a function, I can see exactly what it does. For someone who spends a lot of time tracking down where an allocation came from, that matters to me. Also it is not as heavy or strict as Rust that I know. Which hits a well balanced with providing memory safety, and lightweightnes.&lt;&#x2F;p&gt;
&lt;p&gt;It also works with C directly. Zig treats C as a first-class citizen, which is the phrasing the Zig people like to use, and it fits. As far as motto or selling phrases it is appropriate. A lot of bioinformatics runs on C libraries, and Zig can call them without a separate binding layer.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;You can @cImport a C header and the functions just work. No FFI ceremony, no bindgen step, no build system wrestling.
&lt;&#x2F;span&gt;
. At the end you get one small static binary that you can copy to a cluster and run. That is a nice thing to have.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;rust-is-winning-so-what&quot;&gt;Rust is winning. So what?&lt;&#x2F;h2&gt;
&lt;p&gt;I want to be fair here, because I am not trying to argue against Rust. It is brilliant. It is providing support for Python like I don&#x27;t think we&#x27;ve seen before.
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Polars is a great example: DataFrame operations at Rust speed with a clean Python API.
&lt;&#x2F;span&gt;
&lt;&#x2F;p&gt;
&lt;p&gt;Rust is winning in bioinformatics. It is everywhere, and it is being pushed hard for good reasons. The I&#x2F;O libraries are mature, the format parsers are solid, and even in proteomics there are strong Rust tools in real use. If you are starting a serious project today and ask me what to use, I would tell you Rust.&lt;&#x2F;p&gt;
&lt;p&gt;It is also the better career choice, and I know that. Rust is the thing on the resume and the keyword in the job post. Picking it is the sensible decision.&lt;&#x2F;p&gt;
&lt;p&gt;But where is the fun in that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-boring-tools-niche&quot;&gt;The boring tools niche&lt;&#x2F;h2&gt;
&lt;p&gt;I should be clear about one thing: I have not built anything real in Zig yet. I am putting my thoughts at a very early on stage, at a stage where I am learning a language that I decided to invest in so some bias could be expected.&lt;&#x2F;p&gt;
&lt;p&gt;Everthing I talk about Zig is based on what I’ve seen so far and a personal “Hunch”. But my hunch is specific though. The work I keep coming back to is the boring foundation. Parsers, indexers, validators. Small tools that hold a pipeline together and cause real problems when they break. They tend to be small, they stick around for years, and they have to be fast and predictable on whatever machine the data is on.&lt;&#x2F;p&gt;
&lt;p&gt;That is what Zig looks good for. Small, no runtime, explicit about memory, and easy to use with existing C code. For that kind of tool, it feels like a good fit rather than a compromise.&lt;&#x2F;p&gt;
&lt;p&gt;And it is fun to write. It makes me want to build things on my own time again, which I had not felt much during the PhD.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-honest-caveats&quot;&gt;The honest caveats&lt;&#x2F;h2&gt;
&lt;p&gt;I guess I have to honestly mention caveats, much like most Zig early adapters have to disclose. It goes without saying a language that is pre 1.0 will break your code between releases. That is the plan, not an accident, so if you build on it now you should expect to do migrations.&lt;&#x2F;p&gt;
&lt;p&gt;The ecosystem is small compared to Rust. Fewer libraries, fewer examples, and fewer people who have already solved your problem. Sometimes you have to work it out yourself. There is also no bioinformatics presence, which I might work on closing in the future…???&lt;&#x2F;p&gt;
&lt;p&gt;So I am not telling anyone to skip Rust and bet their career on Zig. Learn Rust and use Rust. It is the smart choice. I just want to spend some of my own time on Zig because I think I will enjoy it, and I think it might have a good adoption in the future.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-land&quot;&gt;Where I land&lt;&#x2F;h2&gt;
&lt;p&gt;I think Zig is a good fit for the foundational tools in bioinformatics, the small fast pieces everything else sits on. I cannot prove that yet, because I have not built it yet.&lt;&#x2F;p&gt;
&lt;p&gt;For now, I will use Rust when the work calls for it, and Zig when I get to choose.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>proDA</title>
        <published>2020-05-05T00:00:00+00:00</published>
        <updated>2020-05-05T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/reading-proda/"/>
        <id>https://eneskemalergin.github.io/blog/reading-proda/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/reading-proda/">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;&#x2F;strong&gt; This reading note was originally written in 2020 when I first encountered the preprint. I have expanded and updated it in June 2026 with proper citation details (the paper was published in &lt;em&gt;Journal of Proteome Research&lt;&#x2F;em&gt; after the initial bioRxiv version) and verified that the proDA package remains actively maintained on Bioconductor.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Paper:&lt;&#x2F;strong&gt; &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.biorxiv.org&#x2F;content&#x2F;10.1101&#x2F;661496v2&quot;&gt;Probabilistic dropout analysis for identifying differentially abundant proteins in label-free mass spectrometry&lt;&#x2F;a&gt;&lt;br &#x2F;&gt;
&lt;strong&gt;Authors:&lt;&#x2F;strong&gt; Constantin Ahlmann-Eltze, Simon Anders&lt;br &#x2F;&gt;
&lt;strong&gt;Published:&lt;&#x2F;strong&gt; bioRxiv (2019), later published in &lt;em&gt;Journal of Proteome Research&lt;&#x2F;em&gt; (2020)
&lt;span class=&quot;sidenote-ref&quot;&gt;◆&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;I originally read the bioRxiv preprint, but if you are citing this in a paper, use the JPR version. — EKE, June 2026
&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Tool:&lt;&#x2F;strong&gt; &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;bioconductor.org&#x2F;packages&#x2F;proDA&quot;&gt;proDA R package&lt;&#x2F;a&gt; | &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;const-ae&#x2F;proDA&quot;&gt;GitHub&lt;&#x2F;a&gt;
&lt;span class=&quot;sidenote-ref&quot;&gt;◆&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;As of June 2026, the GitHub repo shows recent commits and the Bioconductor package is in active release (version 1.26.0). This is not abandonware. The method has held up and the implementation is maintained. — EKE, June 2026
&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Citation:&lt;&#x2F;strong&gt;&lt;br &#x2F;&gt;
Ahlmann-Eltze, C. &amp;amp; Anders, S. proDA: Probabilistic Dropout Analysis for Identifying Differentially Abundant Proteins in Label-Free Mass Spectrometry. &lt;em&gt;J. Proteome Res.&lt;&#x2F;em&gt; &lt;strong&gt;19&lt;&#x2F;strong&gt;, 1761–1774 (2020).&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;why-this-one-stuck&quot;&gt;Why this one stuck&lt;&#x2F;h2&gt;
&lt;p&gt;proDA does offer an option to do testing with missing values. Missing values in label-free data are not always random. You miss the low-intensity stuff systematically. Drop in a median or some small constant and you&#x27;ve quietly invented data points. Your matrix looks complete. Your downstream code runs without complaints. Your p-values are confident. They&#x27;re also biased.&lt;&#x2F;p&gt;
&lt;p&gt;proDA refuses the shortcut. Instead of filling holes, it fits a sigmoidal dropout curve per sample. If a value is missing, the model says &quot;this protein was probably below detection, here&#x27;s the probability distribution for where it might have been.&quot; Then it carries that uncertainty through to the differential abundance test. The error bars grow. The significant hits shrink. The ones that survive are real.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;proDA&amp;#x27;s probabilistic dropout model showing how missing values are modeled as censored observations below detection threshold&quot;&gt;proDA&amp;#x27;s probabilistic dropout model showing how missing values are modeled as censored observations below detection threshold&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;reading&amp;#x2F;proDA.jpg&quot; alt=&quot;proDA&amp;#x27;s probabilistic dropout model showing how missing values are modeled as censored observations below detection threshold&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;The dropout model fits a curve describing missingness probability as a function of intensity. Proteins with all missing values (blue) have wide uncertainty. Proteins with some observations (orange, green) combine observed data with modeled dropout probability. From Ahlmann-Eltze &amp;amp; Anders, 2019.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;h2 id=&quot;what-you-actually-get&quot;&gt;What you actually get&lt;&#x2F;h2&gt;
&lt;p&gt;The practical difference is honest uncertainty. If you have a protein with 5 missing values and 1 weak observation, traditional imputation fills those 5 holes with guesses and pretends you measured 6 data points. proDA says &quot;you measured one marginal signal, the rest fell below detection, here&#x27;s how uncertain your mean estimate actually is.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Your significantly differentially abundant list gets shorter. That&#x27;s the point. The proteins you lose were riding on invented data. The ones you keep passed a test that acknowledges how little you actually saw.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-it-matters&quot;&gt;When it matters&lt;&#x2F;h2&gt;
&lt;p&gt;This matters most when you&#x27;re running differential abundance tests and your FDR depends on accurate variance estimates. If you&#x27;re just making a heatmap for a figure or doing quick exploratory clustering, imputation is fine. The problem is when you impute for visualization and then forget you did it before running statistics.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Final thought:&lt;&#x2F;strong&gt; proDA is a reminder that the data we don&#x27;t see can be just as important as the data we do. Since 2020, there have been many methods have been developed around this idea, I myself even have been using limma with weighted testing and robust linear models to go around imputing missing values. Imputation can give us a false sense of confidence. Modeling missingness explicitly keeps us honest about what our data can actually tell us. — EKE, June 2026&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Prosit</title>
        <published>2019-07-15T00:00:00+00:00</published>
        <updated>2019-07-15T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/reading-prosit/"/>
        <id>https://eneskemalergin.github.io/blog/reading-prosit/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/reading-prosit/">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;&#x2F;strong&gt; I first wrote this note in the summer of 2019, right after the Nature Methods paper dropped. When I rebuild my webside and migrate the posts I had an oppurtunity to provide edits in June 2026 with proper citation details and a check on where the models live now, since the original code repository was retired in favor of newer tooling.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Paper:&lt;&#x2F;strong&gt; &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.nature.com&#x2F;articles&#x2F;s41592-019-0426-7&quot;&gt;Prosit: proteome-wide prediction of peptide tandem mass spectra by deep learning&lt;&#x2F;a&gt;&lt;br &#x2F;&gt;
&lt;strong&gt;Authors:&lt;&#x2F;strong&gt; Siegfried Gessulat, Tobias Schmidt, Daniel P. Zolg, and colleagues (Mathias Wilhelm and Bernhard Kuster labs)&lt;br &#x2F;&gt;
&lt;strong&gt;Published:&lt;&#x2F;strong&gt; &lt;em&gt;Nature Methods&lt;&#x2F;em&gt; (2019), published online 27 May 2019
&lt;span class=&quot;sidenote-ref&quot;&gt;◆&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The Nature Methods version is the one to cite. The preprint history is messy but the final paper is solid. — EKE, June 2026
&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Tool:&lt;&#x2F;strong&gt; &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.proteomicsdb.org&#x2F;prosit&#x2F;&quot;&gt;Prosit on ProteomicsDB&lt;&#x2F;a&gt; | &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;koina.wilhelmlab.org&#x2F;&quot;&gt;Koina model server&lt;&#x2F;a&gt;
&lt;span class=&quot;sidenote-ref&quot;&gt;◆&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;The original kusterlab&#x2F;prosit GitHub repository was archived in August 2023. The models moved to Koina, the training code moved to dlomix, and the rescoring&#x2F;library generation moved to Oktoberfest. The method did not die, it grew up and got integrated into real workflows. — EKE, June 2026
&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Citation:&lt;&#x2F;strong&gt;&lt;br &#x2F;&gt;
Gessulat, S. &lt;em&gt;et al.&lt;&#x2F;em&gt; Prosit: proteome-wide prediction of peptide tandem mass spectra by deep learning. &lt;em&gt;Nat. Methods&lt;&#x2F;em&gt; &lt;strong&gt;16&lt;&#x2F;strong&gt;, 509–518 (2019).&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;why-this-one-stuck&quot;&gt;Why this one stuck&lt;&#x2F;h2&gt;
&lt;p&gt;I had filed spectral prediction under &quot;neat demo&quot; and left it there. Prosit moved it off that shelf. It predicts fragment intensities and indexed retention time from peptide sequence alone, and the predictions are good enough to feed a rescorer or build a DIA library without measuring a single real spectrum.&lt;&#x2F;p&gt;
&lt;p&gt;The shift was not in the math. Predicting spectra from sequence is a straightforward supervised learning problem if you have enough training data. The shift was in the accuracy. The predictions crossed the threshold where people started using them in production pipelines instead of publishing them and moving on.&lt;&#x2F;p&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Mirror plot comparing a Prosit-predicted MS2 spectrum against an observed spectrum, showing close agreement of fragment intensities&quot;&gt;Mirror plot comparing a Prosit-predicted MS2 spectrum against an observed spectrum, showing close agreement of fragment intensities&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;reading&amp;#x2F;prosit_mirror.jpg&quot; alt=&quot;Mirror plot comparing a Prosit-predicted MS2 spectrum against an observed spectrum, showing close agreement of fragment intensities&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;Predicted fragment intensities sit close enough to observed spectra to be used as features for rescoring and as the basis for spectral libraries. From Gessulat et al., 2019.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;&lt;h2 id=&quot;what-you-actually-get&quot;&gt;What you actually get&lt;&#x2F;h2&gt;
&lt;p&gt;The practical impact shows up in two places. First, rescoring gets better. When you give a rescorer predicted spectra as features, it can actually distinguish good matches from noise instead of gambling on search engine scores alone. Second, you can generate spectral libraries for organisms or proteases nobody has measured. Just predict from sequence.&lt;&#x2F;p&gt;
&lt;p&gt;The interesting part is the architecture. It is not complicated. A bidirectional LSTM with attention, nothing exotic. The win came from scale. Enough training data, enough compute, and suddenly predictions that were too noisy to trust become informative.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-it-matters&quot;&gt;When it matters&lt;&#x2F;h2&gt;
&lt;p&gt;This matters when you need spectral libraries for something outside the standard human&#x2F;mouse&#x2F;yeast space, or when rescoring database searches. It matters less if your peptides sit far from the training set. Early models struggle with unusual modifications or nontryptic cleavage. The newer models on Koina handle more, but the boundaries are still real.&lt;&#x2F;p&gt;
&lt;p&gt;The other place it matters is as proof of concept. Prosit showed that deep learning could do more in proteomics than classify spectra. It could generate them. That opened doors. Since 2019, whole workflows have been built around predicted spectra. The method did not stay in papers. It moved into tools people actually run.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;Final thought:&lt;&#x2F;strong&gt; I never used Prosit directly in my own work. What stuck with me was watching spectral prediction go from &quot;interesting idea&quot; to &quot;thing people depend on.&quot; The math is not mysterious. The training data and engineering are what made it work. It is a good example of how deep learning earns its place in proteomics: not by being clever, but by being useful enough that the field builds around it. — EKE, June 2026&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Fuzzy Clustering</title>
        <published>2018-05-01T00:00:00+00:00</published>
        <updated>2018-05-01T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://eneskemalergin.github.io/blog/post-fuzzy-clustering/"/>
        <id>https://eneskemalergin.github.io/blog/post-fuzzy-clustering/</id>
        
        <content type="html" xml:base="https://eneskemalergin.github.io/blog/post-fuzzy-clustering/">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;&#x2F;strong&gt; This post was originally published on my old blog on 2018-05-01 and has been transferred here. I have rewritten parts of the original article for clarity and style while keeping the main story and facts intact. Where my current self disagreed with or wanted to expand on the original, I added margin notes signed &lt;em&gt;— Eke, May 2026&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;I first ran into fuzzy clustering during a machine learning course in my undergrad. The idea that a single data point could belong to multiple clusters at once felt wrong
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Eight years later, this still makes me smile. The friction I felt then is exactly what makes fuzzy clustering interesting. If the answer were obvious, you would not need an algorithm. What I did not appreciate at the time was how naturally this maps onto Bayesian thinking: the membership coefficients are essentially posterior probabilities over cluster assignments. — EKE, May 2026
&lt;&#x2F;span&gt;
. Either something is in a group or it is not, right?&lt;&#x2F;p&gt;
&lt;p&gt;Turns out, the world is rarely that clean.&lt;&#x2F;p&gt;
&lt;p&gt;Think about a customer who buys both hiking gear and cooking equipment. Do you put them in the &quot;outdoors&quot; segment or the &quot;food&quot; segment? A hard clustering algorithm forces you to pick one. Fuzzy clustering says: they are 60% outdoors, 40% food. That is more useful for most real problems
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;Customer segmentation was the go-to example in every 2018 ML tutorial, and I was no exception. The example is fine but it hides a subtle point: the membership coefficients are only as good as the feature space they live in. If your features do not separate the underlying behaviors, the coefficients will look uniform and tell you nothing. I have seen plenty of projects where fuzzy clustering gave near-uniform 1&#x2F;C membership across all points, and the team concluded the algorithm did not work. The algorithm was fine. The features were the problem. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;This post walks through what fuzzy clustering is, how the Fuzzy C-Means algorithm works under the hood, and how to implement it in R and Python.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;clustering-in-two-flavours&quot;&gt;Clustering in two flavours&lt;&#x2F;h2&gt;
&lt;p&gt;Cluster analysis is the task of grouping objects so that objects in the same group are more similar to each other than to objects in other groups&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-1-1&quot;&gt;&lt;a href=&quot;#fn-1&quot;&gt;1&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;. There are dozens of algorithms, but they fall into two broad categories:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hard clustering:&lt;&#x2F;strong&gt; every point belongs to exactly one cluster. K-means, hierarchical clustering, DBSCAN all work this way.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Soft (fuzzy) clustering:&lt;&#x2F;strong&gt; every point has a membership coefficient for each cluster, ranging from 0 to 1. The coefficients sum to 1 across clusters.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Hard clustering is simpler and faster. Fuzzy clustering is more expressive
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;This hard&#x2F;soft binary is itself a simplification. DBSCAN has noise points that belong to no cluster, hierarchical clustering has merges that are fuzzy until you cut the dendrogram, and spectral clustering operates in an embedding where distances themselves are soft. The lines are blurrier than I made them sound here. If I were writing this today I would frame it as a spectrum of assignment granularity rather than a hard dichotomy. — EKE, May 2026
&lt;&#x2F;span&gt;
. Which one you use depends on whether your data has clear boundaries or graded transitions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;fuzzy-c-means&quot;&gt;Fuzzy C-Means&lt;&#x2F;h2&gt;
&lt;p&gt;The Fuzzy C-Means (FCM) algorithm, developed by Dunn in 1973 and improved by Bezdek in 1981&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-2-1&quot;&gt;&lt;a href=&quot;#fn-2&quot;&gt;2&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;, is the fuzzy counterpart to K-means. The core idea is the same: find cluster centers and assign points to them. But instead of a hard assignment, each point gets a membership value for every cluster.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-objective-function&quot;&gt;The objective function&lt;&#x2F;h3&gt;
&lt;p&gt;FCM minimizes the following:&lt;&#x2F;p&gt;
&lt;p&gt;$$ J_m = \sum_{i=1}^{N} \sum_{j=1}^{C} u_{ij}^m |x_i - c_j|^2 $$&lt;&#x2F;p&gt;
&lt;p&gt;where:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;$N$ is the number of data points&lt;&#x2F;li&gt;
&lt;li&gt;$C$ is the number of clusters&lt;&#x2F;li&gt;
&lt;li&gt;$u_{ij}$ is the membership of point $i$ in cluster $j$&lt;&#x2F;li&gt;
&lt;li&gt;$m$ is the fuzziness parameter ($m &amp;gt; 1$), controlling how soft the boundaries are&lt;&#x2F;li&gt;
&lt;li&gt;$x_i$ is the $i$-th data point&lt;&#x2F;li&gt;
&lt;li&gt;$c_j$ is the center of cluster $j$&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Higher $m$ means fuzzier clusters. Standard practice uses $m = 2$&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-3-1&quot;&gt;&lt;a href=&quot;#fn-3&quot;&gt;3&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;One thing I glossed over completely: the choice of $m$ is not arbitrary, and $m = 2$ is not always optimal. Small $m$ (close to 1) makes FCM behave like K-means with near-binary assignments. Large $m$ (above 3) flattens memberships toward uniformity and can make clusters indistinguishable. There is literature on optimizing $m$ via cluster validity indices, but in practice most people pick 2 because Bezdek said so. I have seen datasets where $m = 1.5$ gave much cleaner separation, and others where $m = 3$ was needed to avoid degenerate solutions. Experiment. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-algorithm&quot;&gt;The algorithm&lt;&#x2F;h3&gt;
&lt;p&gt;FCM iterates between two updates until convergence:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;1. Update membership coefficients:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;$$ u_{ij} = \frac{1}{\sum_{k=1}^{C} \left( \frac{|x_i - c_j|}{|x_i - c_k|} \right)^{\frac{2}{m-1}}} $$&lt;&#x2F;p&gt;
&lt;p&gt;This says: if a point is close to cluster $j$ relative to other clusters, its membership in $j$ will be high.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;2. Update cluster centers:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;$$ c_j = \frac{\sum_{i=1}^{N} u_{ij}^m x_i}{\sum_{i=1}^{N} u_{ij}^m} $$&lt;&#x2F;p&gt;
&lt;p&gt;Each cluster center is a weighted average of all points, weighted by their membership to that cluster.&lt;&#x2F;p&gt;
&lt;p&gt;The algorithm repeats these steps until the objective function stops changing (or changes less than some tolerance)&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-4-1&quot;&gt;&lt;a href=&quot;#fn-4&quot;&gt;4&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;A real issue I did not mention: local minima. FCM, like K-means, is sensitive to initialization. Different random starts can produce different clusterings, especially with higher $m$ or many clusters. The standard fix is to run the algorithm multiple times with different seeds and keep the run with the lowest $J_m$, but that adds computation. The &lt;code&gt;skfuzzy&lt;&#x2F;code&gt; implementation does not do this automatically, and &lt;code&gt;fanny()&lt;&#x2F;code&gt; in R has limited support for it. If reproducibility matters, set a seed and report it. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-note&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;📝&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;K-means is a special case of FCM.&lt;&#x2F;strong&gt; If you set $m \to 1$, the memberships become binary and FCM collapses into K-means. In practice, FCM with $m = 2$ is the standard choice.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;what-the-output-looks-like&quot;&gt;What the output looks like&lt;&#x2F;h3&gt;
&lt;p&gt;After convergence, each point has a vector of $C$ membership values. A point near the core of a cluster might have membership $[0.95, 0.03, 0.02]$. A point on the boundary between two clusters might have $[0.45, 0.50, 0.05]$
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;I used to treat these vectors as pure assignment probabilities. They are not. The membership $u_{ij}$ depends on the position of all cluster centers, not just the distance to cluster $j$. If cluster centers shift because of a faraway group, the membership of a point that has not moved can change. This makes temporal or cross-dataset comparisons of membership values tricky unless the cluster centers are aligned first. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;


&lt;details class=&quot;blog-details&quot;&gt;
    &lt;summary&gt;When would you use fuzzy over hard clustering?&lt;&#x2F;summary&gt;
    &lt;div class=&quot;blog-details-content&quot;&gt;&lt;p&gt;Fuzzy clustering shines when cluster boundaries are not sharp. Examples include image segmentation (a pixel can be part sky and part tree), customer segmentation (people have mixed interests), and biological data where expression states grade into each other. Use hard clustering when you need categorical assignments or when your data has natural, well-separated groups.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;details&gt;
&lt;h2 id=&quot;fuzzy-c-means-in-r&quot;&gt;Fuzzy C-Means in R&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;cluster&lt;&#x2F;code&gt; package has &lt;code&gt;fanny()&lt;&#x2F;code&gt; for fuzzy clustering
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;&lt;code&gt;fanny()&lt;&#x2F;code&gt; is fine for basic use, but it has limitations. It cannot handle large datasets well (the distance matrix grows quadratically), and it does not expose the fuzziness parameter $m$ directly (it uses a different parametrisation called &lt;code&gt;memb.exp&lt;&#x2F;code&gt;). The &lt;code&gt;ppclust&lt;&#x2F;code&gt; and &lt;code&gt;fcclust&lt;&#x2F;code&gt; packages offer more modern FCM implementations with better initialization options, but &lt;code&gt;fanny()&lt;&#x2F;code&gt; remains the most battle-tested. If I were writing this section today, I would also mention the &lt;code&gt;clustMixType&lt;&#x2F;code&gt; package for mixed-type data, which is a common real-world scenario. — EKE, May 2026
&lt;&#x2F;span&gt;
. Let us run it on the Iris dataset.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;r&quot;&gt;R&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-r&quot;&gt;library(cluster)
library(factoextra)
library(tidyverse)

iris_df &lt;- iris %&gt;%
  mutate(spec_idx = row_number()) %&gt;%
  unite(&quot;species&quot;, Species, spec_idx, sep = &quot;-&quot;, remove = TRUE) %&gt;%
  column_to_rownames(&quot;species&quot;) %&gt;%
  select(-species)

res.fanny &lt;- fanny(iris_df, 3)

head(res.fanny$membership, 7)&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS1: FCM on the Iris dataset using the cluster package.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;Output:&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;code&quot;&gt;Code&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-code&quot;&gt;[,1]       [,2]       [,3]
setosa-1  0.9115847 0.03714162 0.05127368
setosa-2  0.8641378 0.05659841 0.07926381
setosa-3  0.8720433 0.05381542 0.07414133
setosa-4  0.8459146 0.06419306 0.08989232
setosa-5  0.9001651 0.04205859 0.05777633
setosa-6  0.7648869 0.09848692 0.13662620
setosa-7  0.8601062 0.05878600 0.08110779&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
&lt;&#x2F;div&gt;
&lt;p&gt;The setosa points all have memberships above 0.76 in cluster 1. Clear assignment. The versicolor and virginica points will show more spread
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;A detail I skipped: look at setosa-6. Its membership in cluster 1 is 0.76, noticeably lower than the others. This is a real effect, not noise. Some individual Iris plants in the Fisher dataset have petal&#x2F;sepal measurements that push them toward the versicolor boundary. If I were using these memberships downstream, I would flag setosa-6 as a borderline case worth inspecting. Membership coefficients are diagnostic tools, not just output. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;r&quot;&gt;R&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-r&quot;&gt;fviz_cluster(res.fanny, ellipse.type = &quot;convex&quot;,
             palette = c(&quot;#00AFBB&quot;, &quot;#E7B800&quot;, &quot;#FC4E07&quot;),
             ggtheme = theme_minimal(), legend = &quot;right&quot;)&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS2: Visualise the fuzzy clusters.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Fuzzy cluster plot for the Iris dataset&quot;&gt;Iris plot&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 1&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;fuzzy-clustering&amp;#x2F;cluster_plot.png&quot; alt=&quot;Fuzzy cluster plot for the Iris dataset&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 1.&lt;&#x2F;strong&gt; Fuzzy cluster plot for the Iris dataset.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;Setosa forms a tight cluster on the left. Versicolor and virginica overlap in the middle. That overlap is exactly what the membership coefficients capture. The silhouette plot tells a similar story:&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;r&quot;&gt;R&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-r&quot;&gt;fviz_silhouette(res.fanny, palette = c(&quot;#00AFBB&quot;, &quot;#E7B800&quot;, &quot;#FC4E07&quot;),
                ggtheme = theme_minimal())&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS3: Silhouette plot for cluster quality.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Silhouette plot for the fuzzy clustering result&quot;&gt;Silhouette&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 2&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;fuzzy-clustering&amp;#x2F;silhouette_plot.png&quot; alt=&quot;Silhouette plot for the fuzzy clustering result&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 2.&lt;&#x2F;strong&gt; Silhouette plot for the fuzzy clustering result.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;The average silhouette width of 0.42 is decent. Most points fit their assigned clusters reasonably well, but the overlap between versicolor and virginica pulls the average down.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;fuzzy-c-means-in-python&quot;&gt;Fuzzy C-Means in Python&lt;&#x2F;h2&gt;
&lt;p&gt;Python does not have FCM in scikit-learn
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;In 2026 this is still true. scikit-learn has never added fuzzy clustering to its core API. The maintainers have discussed it multiple times on GitHub issues and always punted, mainly because the demand is low relative to maintenance cost. &lt;code&gt;scikit-fuzzy&lt;&#x2F;code&gt; has been the defacto standard since, but it has not seen a major release in years. If you need something production-ready with modern Python support, consider &lt;code&gt;fuzzy-c-means&lt;&#x2F;code&gt; (PyPI) or implementing the update equations yourself in 30 lines of numpy. The algorithm is simple enough that a custom implementation is often cleaner than wrangling an unmaintained dependency. — EKE, May 2026
&lt;&#x2F;span&gt;
. The &lt;code&gt;scikit-fuzzy&lt;&#x2F;code&gt; (&lt;code&gt;skfuzzy&lt;&#x2F;code&gt;) library fills the gap&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-5-1&quot;&gt;&lt;a href=&quot;#fn-5&quot;&gt;5&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;. Let us generate synthetic data with three known clusters and see how FCM recovers them.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;python&quot;&gt;Python&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
import skfuzzy as fuzz
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_style(&quot;white&quot;)
np.random.seed(42)

centers = [[1, 3], [2, 2], [3, 8]]
sigmas  = [[0.3, 0.5], [0.5, 0.3], [0.5, 0.3]]

xpts, ypts = np.array([]), np.array([])
for (xmu, ymu), (xsigma, ysigma) in zip(centers, sigmas):
    xpts = np.append(xpts, np.random.normal(xmu, xsigma, 200))
    ypts = np.append(ypts, np.random.normal(ymu, ysigma, 200))

plt.figure(figsize=(8, 6))
plt.scatter(xpts, ypts, c=[&quot;b&quot;]*200 + [&quot;orange&quot;]*200 + [&quot;g&quot;]*200, s=10)
plt.title(&quot;Test data: 600 points, 3 clusters&quot;)
plt.show()&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS4: Generate test data with three cluster centers.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Synthetic test data with three cluster centers&quot;&gt;Test data&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 3&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;fuzzy-clustering&amp;#x2F;fuzzy-clustering-4-0.png&quot; alt=&quot;Synthetic test data with three cluster centers&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 3.&lt;&#x2F;strong&gt; Synthetic test data with three visible clusters.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;Three visible clusters. The question is how many clusters FCM finds on its own. The Fuzzy Partition Coefficient (FPC) tells us. FPC ranges from 0 to 1, with 1 meaning perfectly separated clusters
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;FPC and its close relative the Normalized Fuzzy Partition Coefficient (NFPC) are useful heuristics, but they have a well-known bias: they favour compact, spherical clusters with similar sizes. If your data has elongated clusters, varying densities, or very different sizes, FPC will mislead you. There are alternatives: the Xie-Beni index, the Fukuyama-Sugeno index, and the silhouette width (which works for fuzzy assignments too). I did not know about these in 2018 and relied on FPC alone. Do not make the same mistake. — EKE, May 2026
&lt;&#x2F;span&gt;
. Let us fit models with 2 through 10 clusters and compare.&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;python&quot;&gt;Python&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;alldata = np.vstack((xpts, ypts))
fpcs = []

fig, axes = plt.subplots(3, 3, figsize=(10, 10))
colors = [&quot;b&quot;, &quot;orange&quot;, &quot;g&quot;, &quot;r&quot;, &quot;c&quot;, &quot;m&quot;, &quot;y&quot;, &quot;k&quot;, &quot;Brown&quot;]

for ncenters, ax in enumerate(axes.ravel(), start=2):
    cntr, u, _, _, _, _, fpc = fuzz.cluster.cmeans(
        alldata, ncenters, 2, error=0.005, maxiter=1000, init=None
    )
    fpcs.append(fpc)

    cluster_membership = np.argmax(u, axis=0)
    for j in range(ncenters):
        ax.scatter(xpts[cluster_membership == j],
                   ypts[cluster_membership == j],
                   c=colors[j], s=8)
    ax.scatter(cntr[:, 0], cntr[:, 1], marker=&quot;s&quot;, c=&quot;red&quot;, s=60)
    ax.set_title(f&quot;Centers = {ncenters}, FPC = {fpc:.2f}&quot;)
    ax.axis(&quot;off&quot;)

plt.tight_layout()
plt.show()&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS5: Evaluate FPC across different cluster counts.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;Cluster comparison across different cluster counts and FPC values&quot;&gt;Cluster sweep&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 4&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;fuzzy-clustering&amp;#x2F;fuzzy-clustering-6-0.png&quot; alt=&quot;Cluster comparison across different cluster counts and FPC values&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 4.&lt;&#x2F;strong&gt; Cluster comparison across different numbers of centers with FPC values.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;The FPC peaks at 2 clusters, not 3. That is unexpected. Let us check the FPC values directly:&lt;&#x2F;p&gt;


&lt;div class=&quot;code-block-wrapper&quot;&gt;
    &lt;div class=&quot;code-block-header&quot; data-lang=&quot;python&quot;&gt;Python&lt;&#x2F;div&gt;
    &lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;plt.figure(figsize=(8, 5))
plt.plot(range(2, 11), fpcs, &quot;o-&quot;, color=&quot;#731810&quot;)
plt.xlabel(&quot;Number of clusters&quot;)
plt.ylabel(&quot;Fuzzy Partition Coefficient&quot;)
plt.title(&quot;FPC vs Number of Clusters&quot;)
plt.show()&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
    
    &lt;div class=&quot;code-block-caption&quot;&gt;CS6: Plot FPC against number of clusters.&lt;&#x2F;div&gt;
    
&lt;&#x2F;div&gt;



&lt;figure class=&quot;blog-figure&quot;&gt;
    &lt;div class=&quot;blog-figure-frame&quot;&gt;
        
        &lt;div class=&quot;blog-figure-meta&quot; aria-hidden=&quot;true&quot;&gt;
            
            &lt;div class=&quot;blog-figure-alt&quot; title=&quot;FPC plotted against the number of clusters&quot;&gt;FPC curve&lt;&#x2F;div&gt;
            
            
            &lt;div class=&quot;blog-figure-label&quot;&gt;Figure 5&lt;&#x2F;div&gt;
            
        &lt;&#x2F;div&gt;
        
        &lt;div class=&quot;blog-figure-media&quot;&gt;
            &lt;img src=&quot;&amp;#x2F;blog&amp;#x2F;images&amp;#x2F;fuzzy-clustering&amp;#x2F;fuzzy-clustering-8-0.png&quot; alt=&quot;FPC plotted against the number of clusters&quot; loading=&quot;lazy&quot; &#x2F;&gt;
        &lt;&#x2F;div&gt;
        
        &lt;figcaption&gt;&lt;p&gt;&lt;strong&gt;Figure 5.&lt;&#x2F;strong&gt; Fuzzy Partition Coefficient as a function of cluster count.&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
        
    &lt;&#x2F;div&gt;
&lt;&#x2F;figure&gt;
&lt;p&gt;FPC peaks at 2 clusters (0.82) and drops steadily after that. Why would a dataset with three real clusters have a higher FPC at 2?&lt;&#x2F;p&gt;
&lt;p&gt;Look at the data again. The two left clusters (centers at [1,3] and [2,2]) are close together. FCM with 2 clusters merges them into one and keeps the right cluster separate. The resulting partition is cleaner in the FPC sense because the merged cluster is still compact. FPC penalises overlap, and the two left clusters overlap significantly.&lt;&#x2F;p&gt;

&lt;div class=&quot;callout callout-tip&quot;&gt;
    &lt;span class=&quot;callout-icon&quot;&gt;💡&lt;&#x2F;span&gt;
    &lt;div class=&quot;callout-content&quot;&gt;&lt;p&gt;&lt;strong&gt;FPC is not the ground truth.&lt;&#x2F;strong&gt; It tells you how clean your partition is, not whether it matches reality. Always pair FPC with domain knowledge and visual inspection. If you know your data has three meaningful groups, three clusters is the right answer regardless of what FPC says.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;closing-thoughts&quot;&gt;Closing thoughts&lt;&#x2F;h2&gt;
&lt;p&gt;Fuzzy clustering is not a replacement for hard clustering. It is a different tool for a different kind of problem. Use it when your data has graded boundaries, when a point can reasonably belong to multiple groups, or when you need probabilities instead of labels
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;If I were writing this post today, I would add a third use case: diagnostic tool. The membership distribution across points can tell you things about your data that hard assignments hide. High-entropy membership vectors (where no cluster gets above 0.5) are a strong signal that your data does not cluster well, your feature space is poorly chosen, or the number of clusters is wrong. A hard clustering algorithm will still assign every point to some cluster and give you confident-looking labels. Fuzzy clustering forces the ambiguity into the open. That alone is worth the price of entry. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;p&gt;The FCM algorithm itself is simple, well-studied, and implemented in both R and Python&lt;sup class=&quot;footnote-reference&quot; id=&quot;fr-6-1&quot;&gt;&lt;a href=&quot;#fn-6&quot;&gt;6&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;. Start with $m = 2$, validate with FPC and visual inspection, and treat the membership coefficients as the rich information they are
&lt;span class=&quot;sidenote-ref&quot;&gt;&amp;#9670;&lt;&#x2F;span&gt;
&lt;span class=&quot;sidenote&quot;&gt;If there is one thing I want readers to take away from this 2026 annotation, it is this: the membership coefficients are not the final answer. They are the beginning of the analysis. Plot their distributions. Check for high-entropy points. Compare them across runs with different random seeds. Cluster the membership vectors themselves to see if there are meta-clusters of points with similar assignment profiles. The membership matrix often contains structure that is invisible in the original feature space. I missed all of this in 2018. I hope you do not. — EKE, May 2026
&lt;&#x2F;span&gt;
.&lt;&#x2F;p&gt;
&lt;section class=&quot;footnotes&quot;&gt;
&lt;ol class=&quot;footnotes-list&quot;&gt;
&lt;li id=&quot;fn-1&quot;&gt;
&lt;p&gt;The classic definition from Kaufman and Rousseeuw (1990), &lt;em&gt;Finding Groups in Data&lt;&#x2F;em&gt;. &lt;a href=&quot;#fr-1-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-2&quot;&gt;
&lt;p&gt;Dunn&#x27;s 1973 paper introduced the fuzzy ISODATA algorithm; Bezdek generalised it into FCM in 1981. &lt;a href=&quot;#fr-2-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-3&quot;&gt;
&lt;p&gt;Schwammle, V. &amp;amp; Jensen, O.N. (2010). A simple and fast method to determine the parameters for fuzzy c-means cluster analysis. &lt;em&gt;Bioinformatics&lt;&#x2F;em&gt;, 26(22), 2841-2848. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1093&#x2F;bioinformatics&#x2F;btq534&quot;&gt;doi:10.1093&#x2F;bioinformatics&#x2F;btq534&lt;&#x2F;a&gt;. They propose a method to choose $m$ and $C$ simultaneously by finding the point where clustering on randomised data no longer detects structure. &lt;a href=&quot;#fr-3-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-4&quot;&gt;
&lt;p&gt;Matteucci, M. &lt;em&gt;A Tutorial on Clustering Algorithms - Fuzzy C-Means&lt;&#x2F;em&gt;. Available at &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;matteucci.faculty.polimi.it&#x2F;Clustering&#x2F;tutorial_html&#x2F;cmeans.html&quot;&gt;matteucci.faculty.polimi.it&lt;&#x2F;a&gt;. A clear, visual walkthrough of the FCM algorithm with interactive demos. &lt;a href=&quot;#fr-4-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-5&quot;&gt;
&lt;p&gt;Scikit-Fuzzy documentation. &lt;em&gt;Fuzzy c-means clustering&lt;&#x2F;em&gt;. Available at &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;pythonhosted.org&#x2F;scikit-fuzzy&#x2F;auto_examples&#x2F;plot_cmeans.html&quot;&gt;pythonhosted.org&#x2F;scikit-fuzzy&lt;&#x2F;a&gt;. The official example gallery for the skfuzzy library. &lt;a href=&quot;#fr-5-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li id=&quot;fn-6&quot;&gt;
&lt;p&gt;Doring, C., Lesot, M.-J. &amp;amp; Kruse, R. (2006). Data analysis with fuzzy clustering methods. &lt;em&gt;Computational Statistics &amp;amp; Data Analysis&lt;&#x2F;em&gt;, 51(1), 192-214. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;doi.org&#x2F;10.1016&#x2F;j.csda.2006.04.030&quot;&gt;doi:10.1016&#x2F;j.csda.2006.04.030&lt;&#x2F;a&gt;. A comprehensive survey of the fuzzy clustering landscape, covering objective function methods, ACE, and FMLE. &lt;a href=&quot;#fr-6-1&quot;&gt;↩&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;&#x2F;section&gt;
</content>
        
    </entry>
</feed>
