<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aman Singh</title>
    <description>The latest articles on DEV Community by Aman Singh (@amansinghnp).</description>
    <link>https://dev.to/amansinghnp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4199516%2F9c7e7c44-2eb7-48ef-a541-bdb70857e86e.jpg</url>
      <title>DEV Community: Aman Singh</title>
      <link>https://dev.to/amansinghnp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amansinghnp"/>
    <language>en</language>
    <item>
      <title>Four tools for seismic data</title>
      <dc:creator>Aman Singh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:36:20 +0000</pubDate>
      <link>https://dev.to/amansinghnp/four-tools-for-seismic-data-27e1</link>
      <guid>https://dev.to/amansinghnp/four-tools-for-seismic-data-27e1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amansinghnp.com/writing/seismic-pytorch" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Over 2025 and 2026 I built four small open-source tools that sit at different points of a seismic data pipeline. Each solves a different problem, so start with the one you need:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Reach for it if you&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;torch-friction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rate-and-state friction as a differentiable PyTorch layer, so friction parameters can be fitted by gradient descent&lt;/td&gt;
&lt;td&gt;want to recover &lt;em&gt;a&lt;/em&gt;, &lt;em&gt;b&lt;/em&gt; and &lt;em&gt;Dc&lt;/em&gt; from friction data, or put exact friction physics inside a larger network&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/AmanSinghNp/torch-friction" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Basalt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C++ frequency-wavenumber (f-k) filtering, built around memory-mapped SEG-Y, aligned memory, tiled FFTs and AVX2/AVX-512 kernels&lt;/td&gt;
&lt;td&gt;need to remove ground roll from active-source shot gathers&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/AmanSinghNp/Basalt" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fast-trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recursive STA/LTA event detection in C++23 with a NumPy-friendly Python API&lt;/td&gt;
&lt;td&gt;need to flag events in single traces or batches of traces from Python&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/AmanSinghNp/fast-trigger" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seismic-linter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A static analyser (on PyPI) that flags temporal data leakage in ML code before training runs&lt;/td&gt;
&lt;td&gt;train models on earthquake catalogues or other time series and want leaky code blocked at commit time&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/AmanSinghNp/seismic-linter" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each tool's section opens with a short entry: the clearest result, the main limitation and how it was tested. The physics, numerics and full test results follow. Every chart below was produced by running the published code (or, where noted, a line-for-line NumPy port of it) on the data listed in section 6. Some of the results are flattering and some are not. I've kept both. The earthquake-severity model that led to &lt;code&gt;seismic-linter&lt;/code&gt; is on the Projects page as &lt;a href="https://amansinghnp.com/projects#earthquake" rel="noopener noreferrer"&gt;South Asia Earthquake Severity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&amp;lt; 0.05%&lt;/strong&gt; friction parameter error after inversion&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;−20.7 dB&lt;/strong&gt; ground roll removed by the f-k mute&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;≈ 2 dB&lt;/strong&gt; less signal needed by x² STA/LTA at a 1% false-alarm rate&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why build a toolchain
&lt;/h2&gt;

&lt;p&gt;Machine learning in seismology has moved fast. Deep networks now pick phases and detect events at scale (Mousavi &amp;amp; Beroza, 2022), and physics-informed neural networks (PINNs) put governing equations into the loss function (Raissi et al., 2019; Karniadakis et al., 2021). Two problems keep coming up anyway. The first is that the physics is often bolted on after the fact, instead of living inside the computational graph where gradients can reach it. The second is that evaluation is fragile: time-series leakage has inflated reported results across many fields (Kapoor &amp;amp; Narayanan, 2023), and earthquake forecasting has its own history of complex models that turn out no better than simple baselines (Mignan &amp;amp; Broccardo, 2019, 2020).&lt;/p&gt;

&lt;p&gt;The four tools line up against those two problems. &lt;code&gt;torch-friction&lt;/code&gt; and &lt;code&gt;Basalt&lt;/code&gt; handle the physics and signal-processing side. &lt;code&gt;fast-trigger&lt;/code&gt; handles detection. &lt;code&gt;seismic-linter&lt;/code&gt; guards the evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. torch-friction: differentiable rate-and-state friction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;torch-friction in brief&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Result.&lt;/strong&gt; In a teacher–student test at a lab-scale &lt;em&gt;Dc&lt;/em&gt; of 10 µm (Setup B, section 2.5), gradient descent through the solver recovered &lt;em&gt;a&lt;/em&gt;, &lt;em&gt;b&lt;/em&gt; and &lt;em&gt;Dc&lt;/em&gt; to within 0.05% from starting guesses that were 33–100% wrong, and the loss levelled off at the noise variance.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Main limitation.&lt;/strong&gt; Recovery depends on the experiment, not the optimiser. With only 8% of &lt;em&gt;Dc&lt;/em&gt; of slip after a single step (Setup A), &lt;em&gt;b&lt;/em&gt; and &lt;em&gt;Dc&lt;/em&gt; can't be separated and &lt;em&gt;b&lt;/em&gt; moved further from the truth. The layer also solves only the zero-dimensional friction ODE, not the elastodynamic problem around a fault.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;How it was tested.&lt;/strong&gt; Synthetic velocity-step data from a teacher layer with hidden parameters and Gaussian noise (σ = 10&lt;sup&gt;−4&lt;/sup&gt;), fitted by a student layer with Adam; RK4 checked against the exact aging-law relaxation at seven step sizes (error falls 19.7× then 16.8× per halving of Δt). Run with &lt;code&gt;torch-friction&lt;/code&gt; from PyPI in float64 on CPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2.1 The friction law
&lt;/h3&gt;

&lt;p&gt;Laboratory rock friction isn't a constant. It depends on how fast the surfaces slide and on how long the contacts have had to age. Dieterich (1979) and Ruina (1983) captured this with a friction coefficient that depends on slip velocity &lt;em&gt;V&lt;/em&gt; and a state variable &lt;em&gt;θ&lt;/em&gt;, which you can read as the average lifetime of the asperity contacts:&lt;/p&gt;

&lt;p&gt;μ(V, θ) = μ0 + &lt;em&gt;a&lt;/em&gt; ln(V / V0) + &lt;em&gt;b&lt;/em&gt; ln(V0θ / Dc)&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;a&lt;/em&gt; term is the &lt;strong&gt;direct effect&lt;/strong&gt;: friction jumps immediately when velocity changes. The &lt;em&gt;b&lt;/em&gt; term is the &lt;strong&gt;evolution effect&lt;/strong&gt;: as the contact population turns over across a characteristic slip distance &lt;em&gt;Dc&lt;/em&gt;, friction relaxes to a new steady state. At steady state, &lt;em&gt;θ&lt;/em&gt; = &lt;em&gt;Dc&lt;/em&gt;/&lt;em&gt;V&lt;/em&gt; and μss = μ0 + (&lt;em&gt;a&lt;/em&gt; − &lt;em&gt;b&lt;/em&gt;) ln(V/V0). When &lt;em&gt;a&lt;/em&gt; − &lt;em&gt;b&lt;/em&gt; &amp;lt; 0 the fault is &lt;em&gt;velocity-weakening&lt;/em&gt;: sliding faster makes it weaker, which is the precondition for earthquake nucleation (Marone, 1998; Scholz, 1998).&lt;/p&gt;

&lt;p&gt;Whether that weakening actually produces an earthquake depends on the surroundings. In the classic spring-slider model, a block dragged through a spring of stiffness &lt;em&gt;k&lt;/em&gt; slides stably if &lt;em&gt;k&lt;/em&gt; is above a critical stiffness &lt;em&gt;kc&lt;/em&gt; = σ(&lt;em&gt;b&lt;/em&gt; − &lt;em&gt;a&lt;/em&gt;)/&lt;em&gt;Dc&lt;/em&gt;, and stick-slips below it (Ruina, 1983; Rice &amp;amp; Ruina, 1983).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv0ygnpbhq72gans7ttu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhv0ygnpbhq72gans7ttu.png" alt="Spring-slider model producing stick-slip" width="799" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. The spring-slider model. The load point moves at a constant rate, stress builds in the spring while the block sticks, then drops when the block slips. With velocity-weakening friction and &lt;em&gt;k&lt;/em&gt; &amp;lt; &lt;em&gt;kc&lt;/em&gt;, this cycle repeats indefinitely: the laboratory analogue of the seismic cycle.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Two ways the state can evolve
&lt;/h3&gt;

&lt;p&gt;The friction equation says nothing about how &lt;em&gt;θ&lt;/em&gt; changes over time. There are two standard choices, and &lt;code&gt;torch-friction&lt;/code&gt; implements both as &lt;code&gt;law='aging'&lt;/code&gt; and &lt;code&gt;law='slip'&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Law&lt;/th&gt;
&lt;th&gt;dθ/dt&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aging (Dieterich)&lt;/td&gt;
&lt;td&gt;1 − Vθ/Dc
&lt;/td&gt;
&lt;td&gt;State grows with time even at rest, so contacts heal while stationary&lt;/td&gt;
&lt;td&gt;Dieterich (1979)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slip (Ruina)&lt;/td&gt;
&lt;td&gt;−(Vθ/Dc) ln(Vθ/Dc)&lt;/td&gt;
&lt;td&gt;State only evolves with slip; symmetric response to velocity steps up and down&lt;/td&gt;
&lt;td&gt;Ruina (1983)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The standard lab test is a velocity step. Figure 2 runs one through &lt;code&gt;FrictionLayer.forward_trajectory&lt;/code&gt; with &lt;em&gt;a&lt;/em&gt; = 0.015, &lt;em&gt;b&lt;/em&gt; = 0.020 and &lt;em&gt;Dc&lt;/em&gt; = 10 µm, stepping from 1 to 10 µm/s and back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidmrqcjzbph3055pg0wz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidmrqcjzbph3055pg0wz.png" alt="Friction response to velocity steps for aging and slip laws" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2. Velocity step up at t = 2 s and back down at t = 8 s, computed with torch-friction (Δt = 2 ms, RK4). The jump of &lt;em&gt;a&lt;/em&gt;·ln10 ≈ 0.035 is the direct effect; the settling to (&lt;em&gt;a&lt;/em&gt; − &lt;em&gt;b&lt;/em&gt;)·ln10 ≈ −0.012 below the starting level is the evolution effect. The two laws agree on the peak and the steady state but differ on the path between them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The two laws only differ in the transient, but the difference isn't small: up to Δμ = 0.012 about one second after the step up, which is a third of the whole direct effect. The asymmetry is easy to see from the equations. After a tenfold step up, Vθ/Dc = 10, so the aging law evolves at a rate of 1 − 10 = −9 while the slip law evolves at −10 ln 10 ≈ −23. The slip law weakens faster. After the step down (Vθ/Dc = 0.1) it's reversed: 0.9 for aging against 0.23 for slip, so the aging law heals faster. Six seconds after the step down, the aging curve has recovered to μ = 0.586 while the slip curve is still at 0.575. Lab experiments with large velocity steps tend to favour the slip law (Bhattacharya et al., 2015), which is why the library keeps both.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 Integrating the state equation
&lt;/h3&gt;

&lt;p&gt;Each call to the layer advances &lt;em&gt;θ&lt;/em&gt; by one classical fourth-order Runge-Kutta (RK4) step, then evaluates the friction equation at the new state. The core is short:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;torch_friction/layers.py&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rk4_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;k1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;physics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;k2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;physics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k1&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;k3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;physics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;k4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;physics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k3&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;k2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;k3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state equation is stiff when &lt;em&gt;Dc&lt;/em&gt; is small relative to &lt;em&gt;V&lt;/em&gt;·Δt. Linearising the aging law about steady state gives an eigenvalue of −V/Dc, and RK4's stability interval on the negative real axis ends near −2.79 (Hairer &amp;amp; Wanner, 1996), so explicit integration needs Δt &amp;lt; 2.79 Dc/V. The layer warns once Δt goes past 0.5 Dc/Vmax, which leaves a margin of about 5.6× below the stability limit. That margin is sensible, because the accuracy limit arrives well before the stability limit does.&lt;/p&gt;

&lt;p&gt;To check the accuracy, Figure 3 relaxes the aging law from &lt;em&gt;θ&lt;/em&gt;0 = 10 s at constant &lt;em&gt;V&lt;/em&gt;, where the exact solution is &lt;em&gt;θ&lt;/em&gt;(t) = Dc/V + (θ0 − Dc/V)e&lt;sup&gt;−Vt/Dc&lt;/sup&gt;, and compares it with the library's RK4 at seven step sizes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkuhu7di6najgujjxbm63.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkuhu7di6najgujjxbm63.png" alt="RK4 error versus time step on a log-log scale" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3. Error in &lt;em&gt;θ&lt;/em&gt; after 2 s of relaxation against step size. Halving Δt cuts the error by 19.7× and then 16.8×, close to the 2&lt;sup&gt;4&lt;/sup&gt; = 16 of a fourth-order method. Below Δt ≈ 0.06 s the error stops falling at about 3 × 10&lt;sup&gt;−7&lt;/sup&gt; s.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The floor isn't RK4's fault. The layer guards its logarithms and divisions with a 10&lt;sup&gt;−12&lt;/sup&gt; epsilon and a smooth &lt;code&gt;soft_clamp&lt;/code&gt;, and with &lt;em&gt;Dc&lt;/em&gt; = 10&lt;sup&gt;−5&lt;/sup&gt; m those guards shift the steady state by about one part in 10&lt;sup&gt;7&lt;/sup&gt;. That's a deliberate trade: gradients that never hit NaN in exchange for a relative bias far below lab measurement noise. If you're working at very small &lt;em&gt;Dc&lt;/em&gt;, scale the epsilon with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Backpropagating through the solver
&lt;/h3&gt;

&lt;p&gt;Because every operation in the RK4 step is a PyTorch tensor op, autograd records the whole integration. &lt;code&gt;forward_trajectory&lt;/code&gt; unrolls the step T times into preallocated output tensors, and &lt;code&gt;loss.backward()&lt;/code&gt; replays that graph in reverse to get ∂L/∂&lt;em&gt;a&lt;/em&gt;, ∂L/∂&lt;em&gt;b&lt;/em&gt;, ∂L/∂&lt;em&gt;Dc&lt;/em&gt; and ∂L/∂μ0 (Paszke et al., 2019). This is "discretise-then-optimise": the gradients are exact for the discrete solver, at the cost of memory that grows linearly with T. The alternative is the adjoint method used by neural ODEs, which solves a second ODE backwards in time and uses constant memory, but its gradients are only as accurate as that backward solve (Chen et al., 2018). For sequences of a few thousand steps, exact gradients are the better deal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9lzjwidv9ukqyoapno9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9lzjwidv9ukqyoapno9.png" alt="Forward and backward passes through an RK4 friction step" width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4. One cell of the unrolled graph. Black arrows are the forward pass; green arrows are gradients flowing back through all four RK4 stages to the parameters. Parameters are stored in unconstrained form and passed through softplus, so &lt;em&gt;a&lt;/em&gt;, &lt;em&gt;b&lt;/em&gt; and &lt;em&gt;Dc&lt;/em&gt; stay positive whatever the optimiser does.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three other details matter for training. Parameters default to float64, because the evolution term takes logs of quantities that span many orders of magnitude. Negative velocities raise an error instead of being silently clamped. And &lt;code&gt;freeze_parameters(['a', 'Dc'])&lt;/code&gt; lets you fix quantities you've measured independently, which matters a great deal for the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 The inverse problem, and when it's solvable
&lt;/h3&gt;

&lt;p&gt;The test is to generate noisy friction data from a "teacher" layer with hidden parameters, then train a "student" layer, started from wrong values, to recover them. I ran two versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup A&lt;/strong&gt; is the demo in the repository's &lt;code&gt;examples/&lt;/code&gt; folder: one step from 1 to 10 µm/s, &lt;em&gt;Dc&lt;/em&gt; = 10 mm, Δt = 0.1 s, 1,000 samples, Adam at a learning rate of 10&lt;sup&gt;−3&lt;/sup&gt; for 150 epochs. &lt;strong&gt;Setup B&lt;/strong&gt; uses a lab-scale &lt;em&gt;Dc&lt;/em&gt; = 10 µm, a sequence of three steps (up ×10, down ×10, up ×3) over 12 s at Δt = 2 ms, and Adam at 10&lt;sup&gt;−2&lt;/sup&gt; for 600 epochs. Both add Gaussian noise with σ = 10&lt;sup&gt;−4&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqybb5nlsv4ssci4az8c5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqybb5nlsv4ssci4az8c5.png" alt="Relative error of recovered friction parameters during training" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 5. Setup B: relative error of each recovered parameter against epoch, starting from guesses that were 33–100% wrong. All three are within 1% by epoch 166 and within 0.05% at the end. The loss levels off at 9.9 × 10&lt;sup&gt;−9&lt;/sup&gt;, which is the noise variance (10&lt;sup&gt;−8&lt;/sup&gt;): the student is fitting everything except the noise.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;True&lt;/th&gt;
&lt;th&gt;Start&lt;/th&gt;
&lt;th&gt;Setup A (150 epochs)&lt;/th&gt;
&lt;th&gt;Setup B (600 epochs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;a&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;0.015&lt;/td&gt;
&lt;td&gt;0.010&lt;/td&gt;
&lt;td&gt;0.0110&lt;/td&gt;
&lt;td&gt;0.01500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;b&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;0.020&lt;/td&gt;
&lt;td&gt;0.010&lt;/td&gt;
&lt;td&gt;0.0092&lt;/td&gt;
&lt;td&gt;0.02000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;Dc&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;10 mm / 10 µm&lt;/td&gt;
&lt;td&gt;2× true&lt;/td&gt;
&lt;td&gt;21.7 mm&lt;/td&gt;
&lt;td&gt;9.996 µm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;μ0
&lt;/td&gt;
&lt;td&gt;0.600&lt;/td&gt;
&lt;td&gt;0.600&lt;/td&gt;
&lt;td&gt;0.607&lt;/td&gt;
&lt;td&gt;0.60000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Setup A doesn't recover the parameters, and &lt;em&gt;b&lt;/em&gt; actually moves further from the truth. Part of the reason is optimisation (the loss is still 1,000× the noise floor after 150 epochs), but the main reason is &lt;strong&gt;identifiability&lt;/strong&gt;. After the step, the block slides at 10 µm/s for 80 s, which is 0.8 mm, only 8% of &lt;em&gt;Dc&lt;/em&gt;. The evolution effect has barely started, so the data mostly constrain the direct effect plus a linearised evolution term in which &lt;em&gt;b&lt;/em&gt; and &lt;em&gt;Dc&lt;/em&gt; appear together as roughly &lt;em&gt;b&lt;/em&gt;/&lt;em&gt;Dc&lt;/em&gt;. Many (&lt;em&gt;b&lt;/em&gt;, &lt;em&gt;Dc&lt;/em&gt;) pairs fit equally well. Setup B slides many times &lt;em&gt;Dc&lt;/em&gt; after each step and steps in both directions, so the two parameters leave separate fingerprints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design rule&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For rate-and-state inversion, design the experiment before the optimiser. Record at least several &lt;em&gt;Dc&lt;/em&gt; of slip after each step, include steps up and down, and freeze any parameter you've measured some other way. A differentiable solver finds the minimum; it can't create information the data don't contain.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.6 Where this sits in the literature
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;torch-friction&lt;/code&gt; is deliberately narrow. It solves the zero-dimensional friction ODE, not the elastodynamic problem around a fault. Rucker and Erickson (2024) go further and train PINNs for the wave equation with a rate-and-state fault boundary in 1D and 2D, recovering depth-varying friction parameters. Borate et al. (2024) show a PINN can recover rate-and-state parameters from acoustic monitoring of lab stick-slip experiments. A differentiable friction layer is a building block for that kind of model: a component whose physics is exact, placed inside a larger network that learns everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Basalt: f-k filtering at the metal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Basalt in brief&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Result.&lt;/strong&gt; On a synthetic shot gather, the sigmoid mask with vcut = 1,500 m/s kept 99.97% of the reflection energy and 0.85% of the ground-roll energy, a 20.7 dB reduction (section 3.1).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Main limitation.&lt;/strong&gt; There are no throughput numbers yet: every results cell in &lt;code&gt;PERFORMANCE.md&lt;/code&gt; is marked TBD, and the C++ AVX2 kernels weren't run for this post. The kernel also reads only &lt;code&gt;mute_vel_max&lt;/code&gt;, so the reject cone is always symmetric in ±&lt;em&gt;k&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;How it was tested.&lt;/strong&gt; A NumPy port of Basalt's mask applied to a synthetic gather with separately known reflection and ground-roll components, with energy measured on each component. In the repository's CI, a golden checksum for single-thread AVX2 output and a bit-for-bit match between multi-threaded batch and single-threaded output.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3.1 What an f-k filter does
&lt;/h3&gt;

&lt;p&gt;In an active-source shot gather, the reflections geologists care about arrive with high apparent velocities across the receiver spread. Ground roll, the surface waves travelling along the ground, is slow (typically a few hundred m/s), low-frequency and much stronger. A 2D Fourier transform maps each linear event with apparent velocity &lt;em&gt;v&lt;/em&gt; to a line &lt;em&gt;f&lt;/em&gt; = &lt;em&gt;v&lt;/em&gt;·&lt;em&gt;k&lt;/em&gt; in the frequency-wavenumber plane, so the two separate by slope. Muting the slow wedge and transforming back is the "pie-slice" filter (Embree et al., 1963; Yilmaz, 2001).&lt;/p&gt;

&lt;p&gt;Basalt's mute is a smooth sigmoid rather than a hard cut, because a hard edge in the f-k plane rings in the time domain:&lt;/p&gt;

&lt;p&gt;w(f, k) = sigmoid( 10 · ( |f / (k + ε)| − vcut ) / taper )&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07xf6wtscelkq7hnh2k9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07xf6wtscelkq7hnh2k9.png" alt="Three panels: a synthetic shot gather with strong linear ground roll, its f-k amplitude spectrum with the muted region shaded, and the filtered gather with the ground roll removed" width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 6. (a) A synthetic gather: 128 traces at 5 m spacing, 4 ms sampling, four reflectors (2,000–3,600 m/s, 30 Hz Ricker) and two ground-roll modes (320 m/s at 8 Hz, 450 m/s at 12 Hz). (b) Its f-k spectrum; the shaded region, |f/k| &amp;lt; 1,500 m/s, is muted. (c) The result, using a NumPy port of Basalt's mask with vcut = 1,500 m/s and taper = 100 m/s.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Measured on each component separately (the filter is linear), the mask keeps &lt;strong&gt;99.97%&lt;/strong&gt; of the reflection energy and &lt;strong&gt;0.85%&lt;/strong&gt; of the ground-roll energy, a 20.7 dB reduction. The panel also shows the two classic f-k artefacts. The small residue at the top centre is the ground-roll apex at zero offset, where the event is locally flat, so its energy sits near &lt;em&gt;k&lt;/em&gt; = 0 inside the pass region. The faint smears at the edges near 1.0 s are wrap-around: the FFT treats the gather as periodic in offset, so energy that gets cut at one edge leaks back in at the other. Production workflows handle these with spatial tapering and padding before the transform.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Pipeline and memory
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva37ksv0ccnygp0uff67.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva37ksv0ccnygp0uff67.png" alt="Basalt processing pipeline and SIMD lane sweep" width="799" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 7. Basalt's path from disk to filtered data. The lower strip shows the mask kernel sweeping one frequency row eight wavenumbers at a time, one AVX2 register per iteration.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of the engineering is about not moving data. SEG-Y files are memory-mapped rather than read into buffers, and the parser decodes the EBCDIC textual header and legacy IBM-360 floating-point samples in place (SEG Technical Standards Committee, 2017). All working memory comes from a &lt;code&gt;MemoryArena&lt;/code&gt;, a bump allocator with 64-byte alignment, so one gather's scratch space is a single allocation and &lt;code&gt;reset()&lt;/code&gt; frees it in O(1). Complex spectra are stored as a structure of arrays (separate &lt;code&gt;real&lt;/code&gt; and &lt;code&gt;imag&lt;/code&gt; planes), so that eight consecutive real parts load into one 256-bit register without shuffling.&lt;/p&gt;

&lt;p&gt;A 2D FFT is a set of 1D FFTs along rows, a transpose, then 1D FFTs along what used to be columns (Cooley &amp;amp; Tukey, 1965). The transpose is where naive code falls apart: striding down columns of a large matrix misses the cache on nearly every access. Basalt tiles the transpose so each block fits in cache, the approach cache-oblivious algorithms formalise (Frigo et al., 1999), and picks the 2D FFT schedule automatically by problem size, in the same spirit as FFTW's planner (Frigo &amp;amp; Johnson, 2005).&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 The SIMD mask kernel
&lt;/h3&gt;

&lt;p&gt;The filter itself is embarrassingly parallel: every f-k bin gets multiplied by its own weight. The AVX2 path tiles the spectrum (32×64 bins for small gathers, 64×128 by default, 32×96 above 2,048), prefetches 8 to 24 elements ahead, and handles eight bins per instruction:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/filter/fk_filter.cpp (AVX2 path, abridged)&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;__m256&lt;/span&gt; &lt;span class="n"&gt;vk&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_load_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k_vals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;__m256&lt;/span&gt; &lt;span class="n"&gt;vel&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_div_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_mm256_add_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_eps&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;__m256&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_sub_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;absolute_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;v_cut&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;x&lt;/span&gt;                   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_mul_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_steep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;__m256&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fast_sigmoid_avx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_mul_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;imag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_mm256_mul_ps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no branches in the inner loop: the "mute or keep" decision is a multiplication by a weight between 0 and 1, so no lane waits on a mispredicted branch. At runtime, the library uses the AVX-512 path if the CPU reports &lt;code&gt;avx512f&lt;/code&gt;, &lt;code&gt;vl&lt;/code&gt;, &lt;code&gt;bw&lt;/code&gt; and &lt;code&gt;dq&lt;/code&gt;, and otherwise falls back to AVX2. One detail worth knowing: the kernel reads only &lt;code&gt;mute_vel_max&lt;/code&gt;, so the reject cone is always symmetric in ±&lt;em&gt;k&lt;/em&gt;. &lt;code&gt;mute_vel_min&lt;/code&gt; is currently unused.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Performance: what's measured and what isn't
&lt;/h3&gt;

&lt;p&gt;I'm not quoting throughput numbers here, because the repository hasn't published any yet. &lt;code&gt;PERFORMANCE.md&lt;/code&gt; lays out the method: pinned CPU governors, the &lt;code&gt;basalt_bench&lt;/code&gt; sweep across size, threads, SIMD mode, NUMA mode and memory policy, and PMU counters on self-hosted Linux. Every results cell is still marked TBD, because shared CI runners are fine for correctness testing but no good for performance claims. What &lt;em&gt;is&lt;/em&gt; in CI is correctness: a golden checksum for single-thread AVX2 output, and a check that multi-threaded batch output matches single-threaded output bit for bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. fast-trigger: STA/LTA in C++23
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;fast-trigger in brief&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Result.&lt;/strong&gt; The detector triggers on both P and S in a synthetic seismogram. The sharper finding is about the characteristic function: at a fixed 1% false-trigger rate, ObsPy's &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; version reaches 50% detection at about 1.8σ against about 2.25σ for the |&lt;em&gt;x&lt;/em&gt;| that &lt;code&gt;fast-trigger&lt;/code&gt; uses, roughly 2 dB less signal, and picks earlier (a median 0.26 s after onset at 3σ, against 0.39 s). See section 4.3.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Main limitation.&lt;/strong&gt; The characteristic function is fixed at |&lt;em&gt;x&lt;/em&gt;|, which the test above shows is the weaker detector for this signal and noise model. It hasn't yet been benchmarked on real waveforms, and the package isn't on PyPI yet.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;How it was tested.&lt;/strong&gt; An exact NumPy port of the recursion, compared with ObsPy 1.5.1's &lt;code&gt;recursive_sta_lta&lt;/code&gt; on synthetic 100 Hz seismograms with known P and S onsets. Thresholds were calibrated on 300 noise-only traces, with 150 trials per P-wave amplitude and fixed seeds. The repository adds Hypothesis property-based tests and an AddressSanitizer and UBSan build.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4.1 Classic and recursive STA/LTA
&lt;/h3&gt;

&lt;p&gt;The short-term-average over long-term-average ratio is still the most widely used automatic event detector (Allen, 1978; Trnkoczy, 2012). A short window tracks the signal's recent energy, a long window tracks the background, and a trigger fires when their ratio crosses a threshold. The &lt;em&gt;recursive&lt;/em&gt; form replaces the sliding windows with exponential moving averages:&lt;/p&gt;

&lt;p&gt;STAi = cs|xi| + (1 − cs)STAi−1, &amp;nbsp;&amp;nbsp; cs = 1/Nsta&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Classic (sliding window)&lt;/th&gt;
&lt;th&gt;Recursive (exponential)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Work per sample&lt;/td&gt;
&lt;td&gt;O(1) with running sums&lt;/td&gt;
&lt;td&gt;O(1): two multiply-adds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State per trace&lt;/td&gt;
&lt;td&gt;Ring buffer of Nlta samples&lt;/td&gt;
&lt;td&gt;Two floats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory of the past&lt;/td&gt;
&lt;td&gt;Hard cut-off at Nlta
&lt;/td&gt;
&lt;td&gt;Exponential decay, time constant Nlta
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;Needs the buffer carried between chunks&lt;/td&gt;
&lt;td&gt;Needs only the two averages carried over&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Withers et al. (1998) compare these and other trigger algorithms in detail. &lt;code&gt;fast-trigger&lt;/code&gt; implements the recursive form in C++23, starts both averages at the first sample's magnitude, and exposes &lt;code&gt;compute_sta_lta&lt;/code&gt; for a single trace and &lt;code&gt;compute_sta_lta_batch&lt;/code&gt; for a 2D array of traces, the latter parallelised over traces with OpenMP. Inputs are validated at the boundary: empty arrays, zero windows, STA ≥ LTA and windows longer than the trace all raise &lt;code&gt;ValueError&lt;/code&gt; in Python.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 A worked example
&lt;/h3&gt;

&lt;p&gt;Figure 8 applies it to a synthetic 100 Hz seismogram: Gaussian noise plus a 0.2 Hz microseism, a 6 Hz P arrival at 30 s and a stronger 3 Hz S arrival at 40 s. STA is 0.5 s and LTA is 10 s. For comparison, it also shows ObsPy's &lt;code&gt;recursive_sta_lta&lt;/code&gt; (Beyreuther et al., 2010), which uses the same recursion on &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; instead of |&lt;em&gt;x&lt;/em&gt;|.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazpo1zbmavxzth8x659c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazpo1zbmavxzth8x659c.png" alt="Synthetic seismogram and STA/LTA ratios" width="800" height="423"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 8. Top: the seismogram (min/max envelope). Bottom: STA/LTA from both characteristic functions, with each one's threshold set for a 1% false-trigger rate on noise alone (dashed). Both trigger on P and S; the |&lt;em&gt;x&lt;/em&gt;| ratio triggers at 30.23 s and the &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; ratio at 30.16 s. The green line sweeps the trace the way a real-time detector would.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 The characteristic function matters more than the language
&lt;/h3&gt;

&lt;p&gt;Squaring the signal doubles its dynamic range in decibels. Where an arrival lifts the |&lt;em&gt;x&lt;/em&gt;| ratio to about 3.4, it lifts the &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; ratio to about 7.7. The noise-only ratio gets peakier too, though, so the only fair comparison is at an equal false-alarm rate. I calibrated both thresholds on 300 noise-only traces (1.50 for |&lt;em&gt;x&lt;/em&gt;|, 1.92 for &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt;), then measured how often each detects a P wave of known amplitude over 150 trials per level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5hvnayw1duw5i5zyl06.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5hvnayw1duw5i5zyl06.png" alt="Detection probability against signal amplitude" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 9. Detection probability against P-wave amplitude at a fixed 1% false-trigger rate. The &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; function reaches 50% detection at about 1.8σ; |&lt;em&gt;x&lt;/em&gt;| needs about 2.25σ, roughly 2 dB more signal. The &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; function also picks earlier: a median 0.26 s after onset at 3σ, against 0.39 s.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For this signal and noise model, the energy function is simply the better detector, and no amount of C++ speed makes up for missing an event. Making the characteristic function a parameter (|&lt;em&gt;x&lt;/em&gt;|, &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt;, or Allen's (1978) function, which adds a weighted derivative term) is the most useful next change to &lt;code&gt;fast-trigger&lt;/code&gt;. It's also a reminder to benchmark detectors at a fixed false-alarm rate, not at a fixed threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.4 Engineering notes
&lt;/h3&gt;

&lt;p&gt;The C++ core takes &lt;code&gt;std::span&lt;/code&gt; inputs and uses an mdspan-style view for batches, with &lt;code&gt;__restrict&lt;/code&gt; pointers so the compiler can vectorise. Release builds on Linux and macOS use &lt;code&gt;-ffast-math&lt;/code&gt; by default, which is fine for a detector but not for bit-reproducible research, so &lt;code&gt;FAST_TRIGGER_STRICT_FLOAT=ON&lt;/code&gt; restores strict IEEE behaviour. There are Hypothesis property-based tests, and an AddressSanitizer and UBSan build. One practical caveat: because the batch function already uses OpenMP internally, calling it from many Python threads at once oversubscribes the CPU. Use one batch call at a time, or set &lt;code&gt;OMP_NUM_THREADS&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. seismic-linter: catching leakage before training
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;seismic-linter in brief&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Result.&lt;/strong&gt; Version 0.2.0 flagged all three leakage mistakes in an eight-line script (four violations across rules T001, T002 and T003) and exited with code 1, which is what lets it block a merge from a pre-commit hook or a GitHub Action (section 5.2).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Main limitation.&lt;/strong&gt; The rules are heuristics over syntax, not data flow. T002 judges by variable names, so a leaky fit on &lt;code&gt;train_df&lt;/code&gt; gets through; T001 doesn't yet treat &lt;code&gt;ewm&lt;/code&gt; or custom wrappers as safe; T003 only accepts a literal &lt;code&gt;shuffle=False&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;How it was tested.&lt;/strong&gt; Shown here on one deliberately leaky script, with the real output reproduced in section 5.2. Two runtime checks, &lt;code&gt;@verify_monotonicity&lt;/code&gt; and &lt;code&gt;validate_split_integrity&lt;/code&gt;, cover what static analysis can't see.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5.1 Three ways the future leaks into the past
&lt;/h3&gt;

&lt;p&gt;Kaufman et al. (2012) define leakage as information in the training data that wouldn't be available at prediction time. In time series it usually comes from ordinary-looking code. Normalising with a global mean includes test-period statistics in every training row. Fitting a scaler before splitting does the same thing. And a shuffled train/test split lets the model train on 2023 and be tested on 2019. Standard k-fold cross-validation carries the same risk for time series unless it's done with care (Bergmeir &amp;amp; Benítez, 2012). Across 17 fields, Kapoor and Narayanan (2023) found leakage affecting 294 published papers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbrlfzwrgqny1znaa9gp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbrlfzwrgqny1znaa9gp.png" alt="Random split leaking future data versus a temporal split" width="800" height="201"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 10. Top: a shuffled split scatters test years (red) through the record, so the model trains on years that come after the ones it's tested on. Bottom: the temporal split used in the South Asia earthquake severity project, where every validation and test event is later than every training event.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5.2 How the analysis works
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo982rdkrfa0bchwoy6ga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo982rdkrfa0bchwoy6ga.png" alt="seismic-linter analysis pipeline" width="800" height="102"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 11. seismic-linter's pipeline. Notebooks are flattened to code cells, each file is parsed once and cached by content hash, and files are analysed in parallel processes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The linter walks Python's abstract syntax tree and reasons about &lt;em&gt;context&lt;/em&gt;. A call to &lt;code&gt;.mean()&lt;/code&gt; is fine if it follows &lt;code&gt;groupby&lt;/code&gt;, &lt;code&gt;rolling&lt;/code&gt;, &lt;code&gt;expanding&lt;/code&gt; or &lt;code&gt;resample&lt;/code&gt; in the same chain, because those carry temporal boundaries; on a bare DataFrame, it gets flagged.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T001&lt;/td&gt;
&lt;td&gt;Warning&lt;/td&gt;
&lt;td&gt;Global mean / std / min / max without a temporal predecessor&lt;/td&gt;
&lt;td&gt;Rolling or expanding statistics, or statistics fitted on train only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T002&lt;/td&gt;
&lt;td&gt;Info&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.fit()&lt;/code&gt; on generic names such as &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;X&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Split first, fit on &lt;code&gt;X_train&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T003&lt;/td&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;train_test_split&lt;/code&gt; without a literal &lt;code&gt;shuffle=False&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shuffle=False&lt;/code&gt; or a time-based split&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the real output of version 0.2.0 on an eight-line script that makes all three mistakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;seismic-linter lintdemo/
File: lintdemo/train.py
  ❌ &lt;span class="o"&gt;[&lt;/span&gt;Line 6] T001: Global mean&lt;span class="o"&gt;()&lt;/span&gt; may cause temporal leakage.
  ❌ &lt;span class="o"&gt;[&lt;/span&gt;Line 6] T001: Global std&lt;span class="o"&gt;()&lt;/span&gt; may cause temporal leakage.
  ℹ️ &lt;span class="o"&gt;[&lt;/span&gt;Line 7] T002: Model fit&lt;span class="o"&gt;()&lt;/span&gt; called on potential non-training data &lt;span class="s1"&gt;'df'&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
  ❌ &lt;span class="o"&gt;[&lt;/span&gt;Line 8] T003: train_test_split used with &lt;span class="nv"&gt;shuffle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;True on &lt;span class="nb"&gt;time &lt;/span&gt;series

Found 4 potential violation&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;        &lt;span class="c"&gt;# exit code 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-zero exit code is what turns this into a gate: it can run as a pre-commit hook or a GitHub Action, and &lt;code&gt;fail-on&lt;/code&gt; / &lt;code&gt;ignore&lt;/code&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt; control which rules block a merge. Deliberate exceptions get an inline &lt;code&gt;# seismic-linter: ignore T001&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 Runtime guards and limits
&lt;/h3&gt;

&lt;p&gt;Static analysis can't see data, so the package also ships two runtime checks: a &lt;code&gt;@verify_monotonicity(time_column="time")&lt;/code&gt; decorator that rejects unsorted catalogues, and &lt;code&gt;validate_split_integrity(train, test, time_column="time")&lt;/code&gt;, which raises &lt;code&gt;TemporalCausalityError&lt;/code&gt; if any test timestamp is earlier than a training timestamp.&lt;/p&gt;

&lt;p&gt;The static rules are heuristics, and they have limits. T002 decides from variable names, so a leaky fit on a variable called &lt;code&gt;train_df&lt;/code&gt; gets through. T001 doesn't yet recognise &lt;code&gt;ewm&lt;/code&gt; or custom wrappers as safe, so those produce false positives. T003 only accepts a literal &lt;code&gt;shuffle=False&lt;/code&gt;, so a variable holding &lt;code&gt;False&lt;/code&gt; still gets reported. A proper fix would be data-flow analysis that follows which rows reach each &lt;code&gt;fit&lt;/code&gt; call. For a linter whose job is to be run on every commit, false positives you can suppress are a better failure mode than silent misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Datasets
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Used here for&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Synthetic velocity steps&lt;/td&gt;
&lt;td&gt;Generated with torch-friction&lt;/td&gt;
&lt;td&gt;Figures 2, 3, 5 (known ground truth, σ = 10&lt;sup&gt;−4&lt;/sup&gt; noise)&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synthetic seismograms&lt;/td&gt;
&lt;td&gt;Generated with NumPy&lt;/td&gt;
&lt;td&gt;Figures 8–9 (known P and S onsets)&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synthetic shot gather&lt;/td&gt;
&lt;td&gt;Generated with NumPy&lt;/td&gt;
&lt;td&gt;Figure 6 (known reflection and ground-roll components)&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laboratory stick-slip (biaxial shear, Penn State)&lt;/td&gt;
&lt;td&gt;Rouet-Leduc et al. (2017); LANL Earthquake Prediction on Kaggle&lt;/td&gt;
&lt;td&gt;Not used yet: the natural real-data target for torch-friction inversion&lt;/td&gt;
&lt;td&gt;&lt;a href="https://doi.org/10.1002/2017GL074677" rel="noopener noreferrer"&gt;doi.org/10.1002/2017GL074677&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STanford EArthquake Dataset (STEAD)&lt;/td&gt;
&lt;td&gt;Mousavi et al. (2019)&lt;/td&gt;
&lt;td&gt;Not used yet: labelled P/S picks for benchmarking fast-trigger on real waveforms&lt;/td&gt;
&lt;td&gt;&lt;a href="https://doi.org/10.1109/ACCESS.2019.2947848" rel="noopener noreferrer"&gt;doi.org/10.1109/ACCESS.2019.2947848&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I've used synthetics wherever the point was to test an algorithm, because only synthetic data come with exact ground truth. The two "not used yet" rows are where each tool should go next.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Reproducibility notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Friction figures use &lt;code&gt;torch-friction&lt;/code&gt; from PyPI (PyTorch 2.14, float64, CPU). The Setup A and B scripts follow &lt;code&gt;examples/inverse_problem_demo.py&lt;/code&gt;, with the changes described in section 2.5.&lt;/li&gt;
&lt;li&gt;  STA/LTA figures use an exact NumPy port of &lt;code&gt;fast-trigger&lt;/code&gt;'s recursion (same coefficients, same first-sample initialisation), because the package isn't on PyPI yet, and ObsPy 1.5.1 for the &lt;em&gt;x&lt;/em&gt;&lt;sup&gt;2&lt;/sup&gt; comparison. Seeds are fixed.&lt;/li&gt;
&lt;li&gt;  The f-k figure uses a NumPy port of Basalt's sigmoid mask. The C++ kernels need x86-64 with AVX2, so they weren't run for this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You can backpropagate through physics. A friction law can be a layer in a neural network.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Allen, R. V. (1978). Automatic earthquake recognition and timing from single traces. &lt;em&gt;Bulletin of the Seismological Society of America, 68&lt;/em&gt;(5), 1521–1532. &lt;a href="https://doi.org/10.1785/BSSA0680051521" rel="noopener noreferrer"&gt;https://doi.org/10.1785/BSSA0680051521&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Bergmeir, C., &amp;amp; Benítez, J. M. (2012). On the use of cross-validation for time series predictor evaluation. &lt;em&gt;Information Sciences, 191&lt;/em&gt;, 192–213. &lt;a href="https://doi.org/10.1016/j.ins.2011.12.028" rel="noopener noreferrer"&gt;https://doi.org/10.1016/j.ins.2011.12.028&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Beyreuther, M., Barsch, R., Krischer, L., Megies, T., Behr, Y., &amp;amp; Wassermann, J. (2010). ObsPy: A Python toolbox for seismology. &lt;em&gt;Seismological Research Letters, 81&lt;/em&gt;(3), 530–533. &lt;a href="https://doi.org/10.1785/gssrl.81.3.530" rel="noopener noreferrer"&gt;https://doi.org/10.1785/gssrl.81.3.530&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Bhattacharya, P., Rubin, A. M., Bayart, E., Savage, H. M., &amp;amp; Marone, C. (2015). Critical evaluation of state evolution laws in rate and state friction: Fitting large velocity steps in simulated fault gouge with time-dependent healing. &lt;em&gt;Journal of Geophysical Research: Solid Earth, 120&lt;/em&gt;(9), 6365–6385. &lt;a href="https://doi.org/10.1002/2015JB012437" rel="noopener noreferrer"&gt;https://doi.org/10.1002/2015JB012437&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Borate, P., Rivière, J., Marty, S., Marone, C., Kifer, D., &amp;amp; Shokouhi, P. (2024). Physics informed neural network can retrieve rate and state friction parameters from acoustic monitoring of laboratory stick-slip experiments. &lt;em&gt;Scientific Reports, 14&lt;/em&gt;, Article 24624. &lt;a href="https://doi.org/10.1038/s41598-024-75826-y" rel="noopener noreferrer"&gt;https://doi.org/10.1038/s41598-024-75826-y&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Chen, R. T. Q., Rubanova, Y., Bettencourt, J., &amp;amp; Duvenaud, D. (2018). Neural ordinary differential equations. In &lt;em&gt;Advances in Neural Information Processing Systems 31&lt;/em&gt;. &lt;a href="https://arxiv.org/abs/1806.07366" rel="noopener noreferrer"&gt;https://arxiv.org/abs/1806.07366&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Cooley, J. W., &amp;amp; Tukey, J. W. (1965). An algorithm for the machine calculation of complex Fourier series. &lt;em&gt;Mathematics of Computation, 19&lt;/em&gt;(90), 297–301. &lt;a href="https://doi.org/10.1090/S0025-5718-1965-0178586-1" rel="noopener noreferrer"&gt;https://doi.org/10.1090/S0025-5718-1965-0178586-1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Dieterich, J. H. (1979). Modeling of rock friction: 1. Experimental results and constitutive equations. &lt;em&gt;Journal of Geophysical Research: Solid Earth, 84&lt;/em&gt;(B5), 2161–2168. &lt;a href="https://doi.org/10.1029/JB084iB05p02161" rel="noopener noreferrer"&gt;https://doi.org/10.1029/JB084iB05p02161&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Embree, P., Burg, J. P., &amp;amp; Backus, M. M. (1963). Wide-band velocity filtering: The pie-slice process. &lt;em&gt;Geophysics, 28&lt;/em&gt;(6), 948–974. &lt;a href="https://doi.org/10.1190/1.1439310" rel="noopener noreferrer"&gt;https://doi.org/10.1190/1.1439310&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Frigo, M., &amp;amp; Johnson, S. G. (2005). The design and implementation of FFTW3. &lt;em&gt;Proceedings of the IEEE, 93&lt;/em&gt;(2), 216–231. &lt;a href="https://doi.org/10.1109/JPROC.2004.840301" rel="noopener noreferrer"&gt;https://doi.org/10.1109/JPROC.2004.840301&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Frigo, M., Leiserson, C. E., Prokop, H., &amp;amp; Ramachandran, S. (1999). Cache-oblivious algorithms. In &lt;em&gt;40th Annual Symposium on Foundations of Computer Science&lt;/em&gt; (pp. 285–297). IEEE. &lt;a href="https://doi.org/10.1109/SFFCS.1999.814600" rel="noopener noreferrer"&gt;https://doi.org/10.1109/SFFCS.1999.814600&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Hairer, E., &amp;amp; Wanner, G. (1996). &lt;em&gt;Solving ordinary differential equations II: Stiff and differential-algebraic problems&lt;/em&gt; (2nd ed.). Springer. &lt;a href="https://doi.org/10.1007/978-3-642-05221-7" rel="noopener noreferrer"&gt;https://doi.org/10.1007/978-3-642-05221-7&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Kapoor, S., &amp;amp; Narayanan, A. (2023). Leakage and the reproducibility crisis in machine-learning-based science. &lt;em&gt;Patterns, 4&lt;/em&gt;(9), Article 100804. &lt;a href="https://doi.org/10.1016/j.patter.2023.100804" rel="noopener noreferrer"&gt;https://doi.org/10.1016/j.patter.2023.100804&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Karniadakis, G. E., Kevrekidis, I. G., Lu, L., Perdikaris, P., Wang, S., &amp;amp; Yang, L. (2021). Physics-informed machine learning. &lt;em&gt;Nature Reviews Physics, 3&lt;/em&gt;(6), 422–440. &lt;a href="https://doi.org/10.1038/s42254-021-00314-5" rel="noopener noreferrer"&gt;https://doi.org/10.1038/s42254-021-00314-5&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Kaufman, S., Rosset, S., Perlich, C., &amp;amp; Stitelman, O. (2012). Leakage in data mining: Formulation, detection, and avoidance. &lt;em&gt;ACM Transactions on Knowledge Discovery from Data, 6&lt;/em&gt;(4), Article 15. &lt;a href="https://doi.org/10.1145/2382577.2382579" rel="noopener noreferrer"&gt;https://doi.org/10.1145/2382577.2382579&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Marone, C. (1998). Laboratory-derived friction laws and their application to seismic faulting. &lt;em&gt;Annual Review of Earth and Planetary Sciences, 26&lt;/em&gt;, 643–696. &lt;a href="https://doi.org/10.1146/annurev.earth.26.1.643" rel="noopener noreferrer"&gt;https://doi.org/10.1146/annurev.earth.26.1.643&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Mignan, A., &amp;amp; Broccardo, M. (2019). One neuron versus deep learning in aftershock prediction. &lt;em&gt;Nature, 574&lt;/em&gt;(7776), E1–E3. &lt;a href="https://doi.org/10.1038/s41586-019-1582-8" rel="noopener noreferrer"&gt;https://doi.org/10.1038/s41586-019-1582-8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Mignan, A., &amp;amp; Broccardo, M. (2020). Neural network applications in earthquake prediction (1994–2019): Meta-analytic and statistical insights on their limitations. &lt;em&gt;Seismological Research Letters, 91&lt;/em&gt;(4), 2330–2342. &lt;a href="https://doi.org/10.1785/0220200021" rel="noopener noreferrer"&gt;https://doi.org/10.1785/0220200021&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Mousavi, S. M., &amp;amp; Beroza, G. C. (2022). Deep-learning seismology. &lt;em&gt;Science, 377&lt;/em&gt;(6607), Article eabm4470. &lt;a href="https://doi.org/10.1126/science.abm4470" rel="noopener noreferrer"&gt;https://doi.org/10.1126/science.abm4470&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Mousavi, S. M., Sheng, Y., Zhu, W., &amp;amp; Beroza, G. C. (2019). STanford EArthquake Dataset (STEAD): A global data set of seismic signals for AI. &lt;em&gt;IEEE Access, 7&lt;/em&gt;, 179464–179476. &lt;a href="https://doi.org/10.1109/ACCESS.2019.2947848" rel="noopener noreferrer"&gt;https://doi.org/10.1109/ACCESS.2019.2947848&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Paszke, A., Gross, S., Massa, F., Lerer, A., Bradbury, J., Chanan, G., Killeen, T., Lin, Z., Gimelshein, N., Antiga, L., Desmaison, A., Köpf, A., Yang, E., DeVito, Z., Raison, M., Tejani, A., Chilamkurthy, S., Steiner, B., Fang, L., … Chintala, S. (2019). PyTorch: An imperative style, high-performance deep learning library. In &lt;em&gt;Advances in Neural Information Processing Systems 32&lt;/em&gt; (pp. 8024–8035). &lt;a href="https://arxiv.org/abs/1912.01703" rel="noopener noreferrer"&gt;https://arxiv.org/abs/1912.01703&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Raissi, M., Perdikaris, P., &amp;amp; Karniadakis, G. E. (2019). Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations. &lt;em&gt;Journal of Computational Physics, 378&lt;/em&gt;, 686–707. &lt;a href="https://doi.org/10.1016/j.jcp.2018.10.045" rel="noopener noreferrer"&gt;https://doi.org/10.1016/j.jcp.2018.10.045&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Rice, J. R., &amp;amp; Ruina, A. L. (1983). Stability of steady frictional slipping. &lt;em&gt;Journal of Applied Mechanics, 50&lt;/em&gt;(2), 343–349. &lt;a href="https://doi.org/10.1115/1.3167042" rel="noopener noreferrer"&gt;https://doi.org/10.1115/1.3167042&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Rouet-Leduc, B., Hulbert, C., Lubbers, N., Barros, K., Humphreys, C. J., &amp;amp; Johnson, P. A. (2017). Machine learning predicts laboratory earthquakes. &lt;em&gt;Geophysical Research Letters, 44&lt;/em&gt;(18), 9276–9282. &lt;a href="https://doi.org/10.1002/2017GL074677" rel="noopener noreferrer"&gt;https://doi.org/10.1002/2017GL074677&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Rucker, C., &amp;amp; Erickson, B. A. (2024). Physics-informed deep learning of rate-and-state fault friction. &lt;em&gt;Computer Methods in Applied Mechanics and Engineering, 430&lt;/em&gt;, Article 117211. &lt;a href="https://doi.org/10.1016/j.cma.2024.117211" rel="noopener noreferrer"&gt;https://doi.org/10.1016/j.cma.2024.117211&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Ruina, A. (1983). Slip instability and state variable friction laws. &lt;em&gt;Journal of Geophysical Research: Solid Earth, 88&lt;/em&gt;(B12), 10359–10370. &lt;a href="https://doi.org/10.1029/JB088iB12p10359" rel="noopener noreferrer"&gt;https://doi.org/10.1029/JB088iB12p10359&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Scholz, C. H. (1998). Earthquakes and friction laws. &lt;em&gt;Nature, 391&lt;/em&gt;(6662), 37–42. &lt;a href="https://doi.org/10.1038/34097" rel="noopener noreferrer"&gt;https://doi.org/10.1038/34097&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; SEG Technical Standards Committee. (2017). &lt;em&gt;SEG-Y_r2.0: SEG-Y revision 2.0 data exchange format&lt;/em&gt; (R. Hagelund &amp;amp; S. A. Levin, Eds.). Society of Exploration Geophysicists. &lt;a href="https://seg.org/wp-content/uploads/2025/11/seg_y_rev2_0_mar2017.pdf" rel="noopener noreferrer"&gt;https://seg.org/wp-content/uploads/2025/11/seg_y_rev2_0_mar2017.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Singh, A. (2026). &lt;em&gt;torch-friction&lt;/em&gt; (Version 0.1.0) [Computer software]. Zenodo. &lt;a href="https://doi.org/10.5281/zenodo.18366445" rel="noopener noreferrer"&gt;https://doi.org/10.5281/zenodo.18366445&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Trnkoczy, A. (2012). Understanding and parameter setting of STA/LTA trigger algorithm. In P. Bormann (Ed.), &lt;em&gt;New manual of seismological observatory practice 2 (NMSOP-2)&lt;/em&gt;. Deutsches GeoForschungsZentrum GFZ. &lt;a href="https://doi.org/10.2312/GFZ.NMSOP-2_IS_8.1" rel="noopener noreferrer"&gt;https://doi.org/10.2312/GFZ.NMSOP-2_IS_8.1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Withers, M., Aster, R., Young, C., Beiriger, J., Harris, M., Moore, S., &amp;amp; Trujillo, J. (1998). A comparison of select trigger algorithms for automated global seismic phase and event detection. &lt;em&gt;Bulletin of the Seismological Society of America, 88&lt;/em&gt;(1), 95–106. &lt;a href="https://doi.org/10.1785/BSSA0880010095" rel="noopener noreferrer"&gt;https://doi.org/10.1785/BSSA0880010095&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Yilmaz, Ö. (2001). &lt;em&gt;Seismic data analysis: Processing, inversion, and interpretation of seismic data&lt;/em&gt; (2nd ed.). Society of Exploration Geophysicists. &lt;a href="https://doi.org/10.1190/1.9781560801580" rel="noopener noreferrer"&gt;https://doi.org/10.1190/1.9781560801580&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Code &amp;amp; archives&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• &lt;a href="https://github.com/AmanSinghNp/torch-friction" rel="noopener noreferrer"&gt;torch-friction&lt;/a&gt; (&lt;a href="https://doi.org/10.5281/zenodo.18366445" rel="noopener noreferrer"&gt;Zenodo DOI&lt;/a&gt;)&lt;br&gt;&lt;br&gt;
• &lt;a href="https://github.com/AmanSinghNp/Basalt" rel="noopener noreferrer"&gt;Basalt&lt;/a&gt; (&lt;a href="https://doi.org/10.5281/zenodo.18907551" rel="noopener noreferrer"&gt;Zenodo DOI&lt;/a&gt;)&lt;br&gt;&lt;br&gt;
• &lt;a href="https://github.com/AmanSinghNp/fast-trigger" rel="noopener noreferrer"&gt;fast-trigger&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://github.com/AmanSinghNp/seismic-linter" rel="noopener noreferrer"&gt;seismic-linter&lt;/a&gt; (&lt;a href="https://pypi.org/project/seismic-linter/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;)&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was first published at &lt;a href="https://amansinghnp.com/writing/seismic-pytorch" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>python</category>
      <category>cpp</category>
      <category>geophysics</category>
    </item>
    <item>
      <title>The model had memorised the map</title>
      <dc:creator>Aman Singh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:36:04 +0000</pubDate>
      <link>https://dev.to/amansinghnp/the-model-had-memorised-the-map-50hf</link>
      <guid>https://dev.to/amansinghnp/the-model-had-memorised-the-map-50hf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amansinghnp.com/writing/global-seismicity" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The depth classifier in my Kaggle notebook looked accurate. It tells deep earthquakes from shallow ones using only where and when they happened, and on a random 80/20 split it scored 0.856 accuracy and an AUC of 0.909. When I tested it on regions it had never seen, holding out whole 10° cells, the AUC fell to 0.695, towards the 0.5 of chance. Accuracy dropped to 0.801, level with always answering "shallow", and it found one deep event in six. It had learned where deep earthquakes have already happened, not how to find a subduction zone.&lt;/p&gt;

&lt;p&gt;The notebook covers 23,409 significant earthquakes (magnitude 5.5 and above) from 1965 to 2016 [1] and runs in 47 seconds. Its summary made three claims. This report keeps two things apart: Part 1 is what the original notebook did and showed, and Part 2 is the 2026 re-check, where I rebuilt the event table from the notebook's own output and tested each claim. The three results, each linked to its section:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The notebook claimed&lt;/th&gt;
&lt;th&gt;The re-check found&lt;/th&gt;
&lt;th&gt;Key number&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Events above M8.0 release "the vast majority" of the energy&lt;/td&gt;
&lt;td&gt;Holds, but loosely. Sixteen events released half the energy; the 40 events of M8.0 or more released 60.2%.&lt;/td&gt;
&lt;td&gt;16 events, 50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aftershocks decay "consistent with Omori's Law"&lt;/td&gt;
&lt;td&gt;Never tested: the notebook plots the Sumatra sequence and fits nothing. An Omori–Utsu fit I ran afterwards gives ordinary decay exponents.&lt;/td&gt;
&lt;td&gt;p = 0.90 and 1.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The classifier can "accurately distinguish" deep from shallow&lt;/td&gt;
&lt;td&gt;It memorised the map. On regions it had never seen, it falls towards chance.&lt;/td&gt;
&lt;td&gt;AUC 0.909 → 0.695&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Part 1: the original notebook
&lt;/h2&gt;

&lt;p&gt;This part is what the notebook did and printed. The only thing I've added is the region names in the hotspot table.&lt;/p&gt;

&lt;p&gt;The data is the USGS "Significant Earthquakes, 1965–2016" file on Kaggle. Cleaning joins the date and time columns into a timestamp, coerces coordinates, depth and magnitude to numbers, and drops rows missing any of them, leaving 23,409 events. Each event also gets an energy estimate. The rest of the notebook is six sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  an animated Plotly map, one frame per year, with bubble size set to magnitude cubed;&lt;/li&gt;
&lt;li&gt;  cumulative energy over time, with the three largest events annotated;&lt;/li&gt;
&lt;li&gt;  the aftershock timeline of the largest event, 2004 Sumatra (M9.1), within 500 km and 30 days;&lt;/li&gt;
&lt;li&gt;  a 5° × 5° grid ranked by an intensity score, drawn as a density map with the PB2002 plate boundaries on top [2];&lt;/li&gt;
&lt;li&gt;  an explorer filtered by minimum magnitude and a deep-focus switch (below 300 km);&lt;/li&gt;
&lt;li&gt;  a random forest that classifies events as deep (70 km or more) or shallow from latitude, longitude, year and month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Energy over time
&lt;/h3&gt;

&lt;p&gt;Energy comes from the Gutenberg–Richter energy–magnitude relation [3]:&lt;/p&gt;

&lt;p&gt;log10 E = 1.5 M + 4.8 &amp;nbsp;&amp;nbsp;(E in joules)&lt;/p&gt;

&lt;p&gt;Each step of one magnitude unit is about 32 times the energy, so a catalogue that is mostly M5.5 to M6 will have its energy budget set by a handful of great earthquakes. The cumulative curve shows it: a slow ramp for 40 years, then three steps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/earthquake/global-seismicity/cumulative-energy.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h3l0gzk0zr9430rs4rr.jpg" alt="Cumulative seismic energy from 1965 to 2016 rising slowly to about 0.85 times 10 to the 19 joules by 2004, then jumping at the M9.1 Sumatra earthquake in 2004, the M8.8 Chile earthquake in 2010 and the M9.1 Tohoku earthquake in 2011, ending near 2.0 times 10 to the 19 joules." width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1, from the notebook. Cumulative energy release. The 2004, 2010 and 2011 earthquakes together account for a third of the 52-year total.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The notebook's markdown says the top 20 events account for "a disproportionate share" but never prints the share. Its summary goes further and says events above M8.0 account for "the vast majority" of the energy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sumatra aftershocks
&lt;/h3&gt;

&lt;p&gt;The notebook plots the Sumatra sequence within 500 km and 30 days of the mainshock and stops there. Its summary says aftershock sequences "exhibit predictable decay patterns consistent with Omori's Law".&lt;/p&gt;

&lt;h3&gt;
  
  
  Hotspots
&lt;/h3&gt;

&lt;p&gt;Events are binned into 5° cells, and each cell gets a score of ln(1 + count) × maximum magnitude. The top ten:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cell (lat, lon)&lt;/th&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;th&gt;Max M&lt;/th&gt;
&lt;th&gt;Mean depth (km)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;35, 140&lt;/td&gt;
&lt;td&gt;Japan Trench, Honshu&lt;/td&gt;
&lt;td&gt;587&lt;/td&gt;
&lt;td&gt;9.1&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−10, 150&lt;/td&gt;
&lt;td&gt;New Britain, Papua New Guinea&lt;/td&gt;
&lt;td&gt;654&lt;/td&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−15, 165&lt;/td&gt;
&lt;td&gt;Vanuatu&lt;/td&gt;
&lt;td&gt;594&lt;/td&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−20, −175&lt;/td&gt;
&lt;td&gt;Tonga&lt;/td&gt;
&lt;td&gt;534&lt;/td&gt;
&lt;td&gt;8.1&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−25, −180&lt;/td&gt;
&lt;td&gt;Fiji deep zone&lt;/td&gt;
&lt;td&gt;583&lt;/td&gt;
&lt;td&gt;7.8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;328&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40, 145&lt;/td&gt;
&lt;td&gt;Off Hokkaido&lt;/td&gt;
&lt;td&gt;388&lt;/td&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0, 125&lt;/td&gt;
&lt;td&gt;Molucca Sea&lt;/td&gt;
&lt;td&gt;558&lt;/td&gt;
&lt;td&gt;7.7&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0, 95&lt;/td&gt;
&lt;td&gt;Northern Sumatra&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;9.1&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−30, −180&lt;/td&gt;
&lt;td&gt;Kermadec&lt;/td&gt;
&lt;td&gt;366&lt;/td&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;45, 150&lt;/td&gt;
&lt;td&gt;Kuril Islands&lt;/td&gt;
&lt;td&gt;275&lt;/td&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All ten are subduction zones on the western Pacific or in Indonesia. The notebook printed only coordinates; the region names are mine. The Fiji cell's mean depth of 328 km is the one place in the top ten where deep-focus earthquakes dominate.&lt;/p&gt;

&lt;h3&gt;
  
  
  The depth classifier
&lt;/h3&gt;

&lt;p&gt;The label is deep if the focal depth is 70 km or more (the markdown says "greater than 70"; the code uses ≥). 20.3% of events are deep. Features are latitude, longitude, year and month, with depth and magnitude deliberately left out. The model is a scikit-learn random forest [6] with 100 trees capped at depth 10, trained on a random 80/20 split. The hypothesis in the notebook was that deep earthquakes only happen at subduction zones, so location alone should predict depth, and the model would act as "a proxy for identifying tectonic boundaries without explicit plate data". This is what it reported:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;th&gt;Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shallow&lt;/td&gt;
&lt;td&gt;0.852&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;td&gt;0.916&lt;/td&gt;
&lt;td&gt;3,709&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep&lt;/td&gt;
&lt;td&gt;0.901&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.346&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.500&lt;/td&gt;
&lt;td&gt;973&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;0.856&lt;/td&gt;
&lt;td&gt;4,682&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On that basis the notebook's summary says the classifier can "accurately distinguish" the two regimes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/earthquake/global-seismicity/feature-importance.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpo9u9gpsyptw9i3yuzaq.jpg" alt="Random forest feature importance: longitude about 0.51, latitude about 0.41, year about 0.05, month about 0.03." width="799" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2, from the notebook. Feature importance. Year and month together take 8%, which should be close to zero for a question about plate geometry.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: the 2026 re-check
&lt;/h2&gt;

&lt;p&gt;For the re-check I rebuilt the event table from the notebook's own output and tested each claim against it. Every table and chart from here on is mine, not the notebook's.&lt;/p&gt;

&lt;p&gt;The first thing I found was a missing cleaning step. The source file has a &lt;code&gt;Type&lt;/code&gt; column that separates earthquakes from nuclear explosions, and the notebook never reads it. At least 125 events sit on the Semipalatinsk, Novaya Zemlya and Nevada test sites at depths of 0 to 6 km. They barely move the energy totals, but they are in the hotspot counts and the classifier's shallow class.&lt;/p&gt;

&lt;h3&gt;
  
  
  Energy: the claim holds, loosely
&lt;/h3&gt;

&lt;p&gt;Here is the share the notebook never printed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Largest events&lt;/th&gt;
&lt;th&gt;Share of total energy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 (Sumatra, 2004)&lt;/td&gt;
&lt;td&gt;14.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;33.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;39.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;70.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By magnitude band the imbalance is plainer still. Half the energy came from 0.07% of the events.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fax3c60q9kfdtb472dt6n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fax3c60q9kfdtb472dt6n.png" alt="Share of events and share of radiated energy by magnitude band, 23,409 events. M5.5 to 5.9: 68.6 percent of events, 1.7 percent of energy. 6.0 to 6.4: 21.6 and 3.0. 6.5 to 6.9: 6.7 and 5.3. 7.0 to 7.4: 2.2 and 9.5. 7.5 to 7.9: 0.8 and 20.2. 8.0 to 8.4: 0.15 and 18.5. 8.5 to 9.1: 0.03 and 41.6." width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3, from the re-check. Where the energy is. Six events of M8.5 or above, 0.03% of the catalogue, radiated 41.6% of the energy. The 16,054 events below M6.0 radiated 1.7%.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the claim that events above M8.0 account for "the vast majority" of the energy is directionally right but loose: 40 events, 0.17% of the catalogue, released 60.2%. That is a majority, not a vast one, because the M7.5 to M7.9 band alone adds another 20%.&lt;/p&gt;

&lt;p&gt;The notebook's animated map works against this finding. Bubble area is set to magnitude cubed, so an M9.1 is drawn about 4.5 times the size of an M5.5, when it released roughly 250,000 times the energy. On the map, the Sumatra earthquake looks like a slightly larger dot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aftershocks: the claim was never tested
&lt;/h3&gt;

&lt;p&gt;Within the notebook's window of 500 km and 30 days there are 45 aftershocks of M5.5 or more, the largest M6.7, and 22 of them fall in the first day. That is what Omori decay looks like, but a scatter plot is not a test.&lt;/p&gt;

&lt;p&gt;The window is also small for this earthquake. The Sumatra–Andaman rupture ran roughly 1,300 km north from the epicentre [4], so a 500 km circle around the epicentre misses much of it. Widening to 1,500 km roughly doubles the count, to 95.&lt;/p&gt;

&lt;p&gt;I fitted the Omori–Utsu law, n(t) = K / (t + c)&lt;sup&gt;p&lt;/sup&gt; [5], by maximum likelihood over a grid, to both M9.1 events in the catalogue, using 90 days and 1,000 km:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mainshock&lt;/th&gt;
&lt;th&gt;Aftershocks (M5.5+)&lt;/th&gt;
&lt;th&gt;In first day&lt;/th&gt;
&lt;th&gt;p&lt;/th&gt;
&lt;th&gt;c (days)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sumatra, 26 Dec 2004&lt;/td&gt;
&lt;td&gt;114&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.035&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tohoku, 11 Mar 2011&lt;/td&gt;
&lt;td&gt;220&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;td&gt;0.041&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both exponents sit in the usual range of roughly 0.9 to 1.5 [5], so the claim survives. The caveats are real, though. With only M5.5+ events, the counts are small. Catalogues miss many aftershocks in the first hours, when waveforms overlap, which inflates c. And I report no uncertainty on p. This is a check, not a result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hotspots: what the score rewards
&lt;/h3&gt;

&lt;p&gt;Two things about the notebook's score are worth knowing. Maximum magnitude carries as much weight as frequency, which is how northern Sumatra gets into the top ten with 193 events. And the grid splits single systems: Tonga–Kermadec takes three cells and Japan–Kuril three more, so the table understates how concentrated seismicity is.&lt;/p&gt;

&lt;h3&gt;
  
  
  The classifier: it memorised the map
&lt;/h3&gt;

&lt;p&gt;Start with the numbers the notebook reported. Always answering "shallow" scores 0.792 on the same test set, so the model adds six points. It caught 337 of 973 deep events and missed 636. When it does say deep it is usually right (37 false alarms), which means the default 0.5 threshold is the main problem. The ranking is decent: AUC 0.909. Lowering the threshold to 0.3 gives recall 0.66 at precision 0.74; at 0.2, recall 0.88 at precision 0.50. Letting the trees grow past depth 10, on latitude and longitude alone, lifts the AUC to 0.945 and recall at 0.5 to 0.73.&lt;/p&gt;

&lt;p&gt;The bigger problem is the split. A random split scatters every cluster across training and test sets. Aftershocks of the same mainshock, and the hundreds of events on the same slab, end up on both sides, so to label a test event the forest only has to remember what happened a few kilometres away. That tests interpolation within known seismic zones. It does not test the hypothesis, which is about recognising tectonic structure.&lt;/p&gt;

&lt;p&gt;To test that, I grouped events into 10° cells and ran 5-fold cross-validation with whole cells held out, so every test event comes from a region the model has not seen [7]. I also ran a time split, training on 1965–2005 and testing on 2006–2016.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp6knrwijsl70k0e87uwt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp6knrwijsl70k0e87uwt.png" alt="ROC AUC of the deep versus shallow random forest under three evaluations. Random 80/20 split: 0.909. Train on 1965 to 2005, test on 2006 to 2016: 0.906. Unseen 10 degree cells with grouped 5-fold cross-validation: 0.695. Axis starts at 0.5, which is chance." width="800" height="239"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4, from the re-check. The same model, three ways of testing it. It holds up when tested on later years and falls towards chance when tested on places it has never seen.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Evaluation&lt;/th&gt;
&lt;th&gt;AUC&lt;/th&gt;
&lt;th&gt;Deep recall at 0.5&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;All-shallow baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Random 80/20 (notebook)&lt;/td&gt;
&lt;td&gt;0.909&lt;/td&gt;
&lt;td&gt;0.346&lt;/td&gt;
&lt;td&gt;0.856&lt;/td&gt;
&lt;td&gt;0.792&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time split, test 2006–2016&lt;/td&gt;
&lt;td&gt;0.906&lt;/td&gt;
&lt;td&gt;0.316&lt;/td&gt;
&lt;td&gt;0.877&lt;/td&gt;
&lt;td&gt;0.827&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unseen 10° cells, 5-fold&lt;/td&gt;
&lt;td&gt;0.695 ± 0.082&lt;/td&gt;
&lt;td&gt;0.168&lt;/td&gt;
&lt;td&gt;0.801&lt;/td&gt;
&lt;td&gt;≈ 0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The time split barely moves anything, because subduction zones do not move in fifty years. Held-out regions are different: accuracy falls to the baseline and the model finds one deep event in six. What it learned is a lookup table of where deep earthquakes have already happened. That is a fair thing to know, but it is not a way to find plate boundaries without plate data, and a random split could not have told me so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Filter to &lt;code&gt;Type == "Earthquake"&lt;/code&gt; before anything else.&lt;/li&gt;
&lt;li&gt;  Print the energy shares instead of describing them, and say "majority" where the number is 60%.&lt;/li&gt;
&lt;li&gt;  Scale map bubbles by energy, or state on the chart that they are not.&lt;/li&gt;
&lt;li&gt;  Fit Omori–Utsu with a completeness cut and confidence intervals, or drop the claim from the summary.&lt;/li&gt;
&lt;li&gt;  Evaluate the classifier with spatially grouped folds, report recall and AUC next to a baseline, and drop year and month.&lt;/li&gt;
&lt;li&gt;  Recast the classifier's goal: predict depth for new events in known zones, which it can do, rather than find boundaries, which it cannot.&lt;/li&gt;
&lt;li&gt;  Use one magnitude type for energy. The catalogue mixes Mw, Ms and mb, and body-wave magnitudes saturate for large events.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A model can look accurate just by memorising where things happen. Test it somewhere it hasn't seen.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; U.S. Geological Survey. &lt;a href="https://www.kaggle.com/datasets/usgs/earthquake-database" rel="noopener noreferrer"&gt;Significant Earthquakes, 1965–2016&lt;/a&gt;. Kaggle dataset.&lt;/li&gt;
&lt;li&gt; Bird, P. (2003). An updated digital model of plate boundaries. &lt;em&gt;Geochemistry, Geophysics, Geosystems&lt;/em&gt;, 4(3), 1027. &lt;a href="https://doi.org/10.1029/2001GC000252" rel="noopener noreferrer"&gt;doi:10.1029/2001GC000252&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; Gutenberg, B. and Richter, C. F. (1956). Magnitude and energy of earthquakes. &lt;em&gt;Annali di Geofisica&lt;/em&gt;, 9, 1–15.&lt;/li&gt;
&lt;li&gt; Lay, T. et al. (2005). The great Sumatra–Andaman earthquake of 26 December 2004. &lt;em&gt;Science&lt;/em&gt;, 308(5725), 1127–1133. &lt;a href="https://doi.org/10.1126/science.1112250" rel="noopener noreferrer"&gt;doi:10.1126/science.1112250&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; Utsu, T., Ogata, Y. and Matsu'ura, R. S. (1995). The centenary of the Omori formula for a decay law of aftershock activity. &lt;em&gt;Journal of Physics of the Earth&lt;/em&gt;, 43(1), 1–33. &lt;a href="https://doi.org/10.4294/jpe1952.43.1" rel="noopener noreferrer"&gt;doi:10.4294/jpe1952.43.1&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; Pedregosa, F. et al. (2011). Scikit-learn: Machine Learning in Python. &lt;em&gt;Journal of Machine Learning Research&lt;/em&gt;, 12, 2825–2830.&lt;/li&gt;
&lt;li&gt; Roberts, D. R. et al. (2017). Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. &lt;em&gt;Ecography&lt;/em&gt;, 40(8), 913–929. &lt;a href="https://doi.org/10.1111/ecog.02881" rel="noopener noreferrer"&gt;doi:10.1111/ecog.02881&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Code &amp;amp; data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• &lt;a href="https://www.kaggle.com/code/amansinghnp/spatiotemporal-analysis-of-global-seismicity" rel="noopener noreferrer"&gt;Spatiotemporal Analysis of Global Seismicity&lt;/a&gt; (Kaggle notebook, Apache 2.0)&lt;br&gt;&lt;br&gt;
• &lt;a href="https://www.kaggle.com/datasets/usgs/earthquake-database" rel="noopener noreferrer"&gt;Significant Earthquakes, 1965–2016&lt;/a&gt; (USGS, via Kaggle)&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was first published at &lt;a href="https://amansinghnp.com/writing/global-seismicity" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>dataviz</category>
    </item>
    <item>
      <title>Reading handwritten postcodes</title>
      <dc:creator>Aman Singh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:35:49 +0000</pubDate>
      <link>https://dev.to/amansinghnp/reading-handwritten-postcodes-5978</link>
      <guid>https://dev.to/amansinghnp/reading-handwritten-postcodes-5978</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amansinghnp.com/writing/lipisort-deeplearning" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;LiPi is a web app that reads the postcode on a handwritten postcard. You upload a photo or hold a card up to a webcam, and LiPi straightens the image, works out which way up it is, finds the ten regions of a postcard with a fine-tuned YOLO11s detector, reads the postcode with OCR and looks up the matching NSW suburb. We connected it to a robotic arm, so the card can go from photograph to tray.&lt;/p&gt;

&lt;p&gt;Real mail is full of personal details, so we didn't train on it. The training set is 1,200 synthetic postcards generated in Python, plus 110 real handwritten ones written by volunteers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/gui-camera.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8c7zf9swymm0bxmbjo44.jpg" alt="LiPi camera tab showing a photographed handwritten postcard addressed to Coffs Harbour NSW 2450, with the result summary below" width="376" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. A handwritten card photographed through the app's camera tab.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/gui-results.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxw228owgr6v2tdh45imw.jpg" alt="LiPi results page with summary tiles reading postcode 2450 and a grid of the original, boundary, straightened and annotated postcard" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. The same card found, straightened and boxed region by region, with the postcode read as 2450.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fea4r0cljopch1qsrvyh7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fea4r0cljopch1qsrvyh7.png" alt="Step 3: the postcode the app read goes to the robotic arm, which places the card in the right tray." width="800" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. With the arm connected, the postcode decides which tray the card goes in (section 9).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On validation, the detector reached an mAP50 of 0.995 and an mAP50-95 of about 0.88. Both numbers are for region detection: finding and labelling the ten postcard regions on the 124-image validation split (the dataset splits 1,066 / 124 / 120). They don't tell you how often LiPi reads the postcode correctly. We haven't measured end-to-end postcode reading accuracy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1,310&lt;/strong&gt; postcard images, 110 of them real&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10&lt;/strong&gt; annotated classes&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.995&lt;/strong&gt; validation mAP50, region detection (124 images)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.88&lt;/strong&gt; validation mAP50-95, region detection (124 images)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~8 min&lt;/strong&gt; training on one T4 GPU&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19.2 MB&lt;/strong&gt; model size&lt;/p&gt;

&lt;p&gt;Two teammates and I built it, then showed it at the &lt;a href="https://utsfeitshowcases.org/ai-showcase/" rel="noopener noreferrer"&gt;AI Showcase&lt;/a&gt; at &lt;a href="https://www.uts.edu.au/events/uts-tech-festival" rel="noopener noreferrer"&gt;UTS Tech Festival 2026&lt;/a&gt;, where it won the People's Choice Award. The app is &lt;a href="https://huggingface.co/spaces/AmanSNP/postcard-yolo-app" rel="noopener noreferrer"&gt;live on Hugging Face Spaces&lt;/a&gt; if you'd like to try it with your own postcard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=5NoXNYZFsEk" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6z2pln2ry6j2ndpset6z.jpg" alt="Video: LiPi Sort demo video" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our showcase video, published on the UTS AI Showcase 2026 channel.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.1 The problem
&lt;/h3&gt;

&lt;p&gt;Automated mail sorting already exists, but it's built for large postal operators with industrial sorting lines. Smaller organisations that receive mail still sort it by hand.&lt;/p&gt;

&lt;p&gt;Reading a handwritten postcode from a photo is much harder than recognising digits in a clean dataset. The postcode sits inside a full handwritten address block. People photograph cards at an angle or in poor light, the card may be tilted or partly covered [1], and handwriting varies widely from writer to writer [2]. Our goal was a lightweight system that can take an ordinary photo of a postcard, find the postcode and read the correct four-digit NSW postcode despite all of that.&lt;/p&gt;

&lt;p&gt;Finding where the postcode sits is a problem in its own right: earlier work used a deep CNN to detect the pincode for postal automation [5]. LiPi does the same detection step with YOLO11s before it reads the digits.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Motivation
&lt;/h3&gt;

&lt;p&gt;We had two reasons. The practical one: university campuses, apartment complexes and community organisations handle a lot of mail but can't justify sorting infrastructure, so we wanted something that runs on a standard laptop and cuts down manual effort and sorting mistakes. The research one: could a model trained almost entirely on synthetic postcards hold up on messy real-world photos? Using real public mail as a dataset was never an option because of privacy, so the whole project depended on synthetic data being good enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.3 Where it fits
&lt;/h3&gt;

&lt;p&gt;In the app, you upload a photo or snap one with your camera and get back the detected postcode, its NSW suburb and the location of every other region on the card, in seconds. The obvious next step is a mailroom workflow where a camera scans incoming postcards and routes each one to the right tray, which is the job of the robotic arm we built (see section 9).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Dataset
&lt;/h2&gt;

&lt;p&gt;The dataset was built in three stages and holds 1,310 postcard images: 1,200 synthetic and 110 real. Prior work shows that combining synthetic and real data improves generalisation in handwritten document recognition [3], and that's the approach we followed. It gave us a large, varied, labelled set without collecting hundreds of real home addresses.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycihpk82v669h3552mbm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycihpk82v669h3552mbm.png" alt="Dataset composition: 1,200 synthetic and 110 real handwritten postcards, 1,310 in total, split into 1,066 training, 124 validation and 120 test images." width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1,310 postcard images in total.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Postcard templates
&lt;/h3&gt;

&lt;p&gt;We visited several Australia Post branches and photographed blank NSW Air Mail postcards under different conditions. Eight real templates became the base for everything we generated.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Synthetic generation
&lt;/h3&gt;

&lt;p&gt;Following the approach of synthetic OCR dataset generators [4], we built a generation pipeline in Python with Pillow that produced 1,200 postcards. Each one has handwritten-style content: a recipient address, a message and a date, rendered in handwriting fonts with random variation in character size, baseline and rotation. Stamps and postmarks are generated procedurally with varying placement and appearance, and the ink changes between blue ballpoint, black ink, dark grey pencil, green ink, dark red-brown ink and marker-style strokes.&lt;/p&gt;

&lt;p&gt;Getting there took a lot of experimenting. Before settling on the final Pillow compositor, we tried compositing and overlaying augmented real postcard photos, and generating images with Nano Banana 2 and GPT-5.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/synthetic-samples.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ircl52w3qx94epbef5n.jpg" alt="Contact sheet of synthetic NSW Air Mail postcards with coloured bounding boxes around each annotated region" width="626" height="678"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A contact sheet of synthetic postcards with their annotations.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 Real handwritten postcards
&lt;/h3&gt;

&lt;p&gt;To cover genuine handwriting, eight people first wrote 84 postcards, which were photographed and annotated by hand in CVAT.ai using the YOLO format. We later wrote and labelled 26 more, bringing the real set to 110. The real samples went into the training and validation splits, while the original test set stayed unchanged so results remained comparable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/real-sample.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6tj2694jdf2v0jpqvam.jpg" alt="A real handwritten postcard addressed to Luigi Wood, NSW 2120, with YOLO bounding boxes around the logo, barcode, message, address and postcode" width="695" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A real handwritten postcard with its YOLO annotations.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Classes and splits
&lt;/h3&gt;

&lt;p&gt;Every postcard is annotated with ten classes: &lt;code&gt;brand_logo&lt;/code&gt;, &lt;code&gt;barcode&lt;/code&gt;, &lt;code&gt;service_title&lt;/code&gt;, &lt;code&gt;stamp_imprint&lt;/code&gt;, &lt;code&gt;publisher_imprint&lt;/code&gt;, &lt;code&gt;date_note&lt;/code&gt;, &lt;code&gt;message_block&lt;/code&gt;, &lt;code&gt;address_block&lt;/code&gt;, &lt;code&gt;postcode_line&lt;/code&gt; and &lt;code&gt;postmark&lt;/code&gt;. The final split is 1,066 training, 124 validation and 120 test images, roughly 80/10/10.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. System design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Pipeline overview
&lt;/h3&gt;

&lt;p&gt;LiPi runs every image through the same seven stages.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fciztprql7atcuaqw8nr6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fciztprql7atcuaqw8nr6.png" alt="LiPi pipeline: input, rectify, orientation, detection, text extraction, suburb lookup, output." width="800" height="1186"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The seven stages every postcard goes through.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The app has two modes. Default uses fixed, recommended settings: the postcard detector v2 weights, a 0.25 confidence threshold, hybrid rectification and EasyOCR. Advanced exposes the controls: model weights, confidence threshold, rectifier strategy, manual rotation and OCR backend. Before anything runs, the app checks that the model weights exist. The full flowchart from our report shows every branch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/flowchart.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fklc6btc2josd67199cet.jpg" alt="Detailed flowchart of the LiPi code: input, mode selection, rectifier, orientation, text extraction and output, with every decision branch" width="800" height="1408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full code flowchart, including every fallback path. Click to open it full size.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Rectification
&lt;/h3&gt;

&lt;p&gt;The first job is turning a photo taken at an angle into a flat, front-facing postcard. In Hybrid or Learned mode, a DocAligner model (FastViT-SA24 heatmap regression) predicts the four corners of the card, and OpenCV's &lt;code&gt;getPerspectiveTransform&lt;/code&gt; and &lt;code&gt;warpPerspective&lt;/code&gt; warp it flat.&lt;/p&gt;

&lt;p&gt;If the learned result fails a geometry check (a minimum area fraction of 0.18 plus a rectangularity score), a contour-based fallback takes over. It applies CLAHE contrast enhancement, bilateral denoising, Gaussian blur, several Canny edge thresholds, adaptive thresholding and morphological gradients to build candidate edge masks, then evaluates polygon approximations and minimum-area rectangles to find the best quad. If nothing valid is found, the original image is used. After warping, any leftover skew is estimated with a weighted Hough line analysis and corrected if it's more than 0.35 degrees, and the image is normalised to landscape.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Orientation
&lt;/h3&gt;

&lt;p&gt;A flat postcard can still be upside down. Rather than train a separate orientation model, we run YOLO11s at all four cardinal rotations and score each run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgsdenael8usngso8rph.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgsdenael8usngso8rph.png" alt="Orientation: YOLO11s runs at 0, 90 clockwise, 180 and 90 counter-clockwise. Each run is scored as a weighted sum of detection confidences, with postcode\_line weighted 1.45 and address\_block 1.25, and the highest-scoring rotation is kept." width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The postcode and address regions count for more, because they matter most downstream.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Detection
&lt;/h3&gt;

&lt;p&gt;The correctly oriented image goes to YOLO11s at a confidence threshold of 0.25, which returns bounding boxes and confidences for the ten postcard classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.5 Text extraction
&lt;/h3&gt;

&lt;p&gt;OCR candidates come from the detected &lt;code&gt;postcode_line&lt;/code&gt; crop (with about 10% padding) plus up to five sub-crops of the &lt;code&gt;address_block&lt;/code&gt;. Each crop is upscaled 2.5× with bicubic interpolation and cleaned up with CLAHE and a Gaussian blur, then read in four variants: CLAHE enhanced, Otsu binary, adaptive threshold and inverted Otsu. Every result is scored on confidence, whether it looks like a valid postcode, and length, and the best one wins.&lt;/p&gt;

&lt;p&gt;There are four OCR backends: EasyOCR, Tesseract, a Gemini or Groq vision-language model, or EasyOCR first with the VLM called only when EasyOCR finds no postcode. We evaluated Tesseract in depth against the other options, which shaped the final choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.6 Suburb lookup and output
&lt;/h3&gt;

&lt;p&gt;The postcode is matched against the Zippopotam.us API to get the NSW suburb. Results appear across three tabs: a visual check of every stage, the postcode with its suburb and raw OCR text, and a debug view with the full detections table and preprocessing and OCR logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Model
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4.1 YOLO11s
&lt;/h3&gt;

&lt;p&gt;The detector is YOLO11s, the small variant of Ultralytics' YOLO11: a single-stage, anchor-free object detector with 182 layers, 9.4 million parameters and 21.6 GFLOPs. The backbone uses C3k2 blocks with SPPF and C2PSA attention modules, and the detection head predicts boxes at three feature scales. We started from COCO-pretrained weights and fine-tuned on the postcard dataset.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/yolo11s-architecture.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5ulbrv3uktqj8c5j60w.jpg" alt="YOLO11s architecture diagram: input image, convolution stem, C3k2 backbone blocks, SPPF, C2PSA, neck with upsampling and concatenation, and a detection head at three scales outputting 10 postcard classes" width="800" height="1094"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;YOLO11s as used in LiPi, from input image to the three-scale detection head.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Why YOLO11s
&lt;/h3&gt;

&lt;p&gt;We also tried Faster R-CNN and SSD using TorchVision. YOLO11s gave the best trade-off between speed and accuracy, and at 19.2 MB it runs comfortably on ordinary hardware with no specialised infrastructure. Along the way, we trained an early, smaller YOLO model that helped validate the detection approach, and tested on a smaller preview dataset and an augmented variant (tilts, blur, background clutter) before scaling up to the full 1,200 images.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Training setup
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base model&lt;/td&gt;
&lt;td&gt;YOLO11s, pretrained on COCO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;960 × 960&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch size&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epochs&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimiser&lt;/td&gt;
&lt;td&gt;AdamW (lr 0.000714, momentum 0.9)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weight decay&lt;/td&gt;
&lt;td&gt;0.0005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed precision&lt;/td&gt;
&lt;td&gt;AMP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset&lt;/td&gt;
&lt;td&gt;1,200 synthetic + 110 real handwritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device&lt;/td&gt;
&lt;td&gt;Tesla T4 GPU (Kaggle)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training time&lt;/td&gt;
&lt;td&gt;about 8 minutes (0.135 hours)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  5. Results
&lt;/h2&gt;

&lt;p&gt;Everything in this section measures region detection: how well YOLO11s finds and labels the ten postcard regions on the 124-image validation split. It does not measure whether LiPi reads the postcode correctly. We didn't build an exact-match benchmark for end-to-end postcode reading, so that accuracy is unknown (see section 7).&lt;/p&gt;

&lt;p&gt;Across all classes, precision was 0.999, recall 1, mAP50 0.995 and mAP50-95 0.88. Every class reached an mAP50 of 0.995. On the stricter mAP50-95, eight classes scored 0.995 and two fell below: &lt;code&gt;postcode_line&lt;/code&gt; at 0.93 and &lt;code&gt;date_note&lt;/code&gt; at 0.896. F1 peaks at 0.97 at a confidence of about 0.375.&lt;/p&gt;

&lt;p&gt;The confusion matrix is almost perfectly diagonal. The one weak spot is &lt;code&gt;date_note&lt;/code&gt;, with a normalised recall of 0.67 and some confusion with the background. That's expected: date notes are the smallest and most variable region on a postcard, and none of our real handwritten postcards include one.&lt;/p&gt;

&lt;p&gt;The full per-class table and chart, the confusion matrices, the training, F1 and precision-recall curves, and the validation prediction grids are in Appendix A.1 to A.4.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The app
&lt;/h2&gt;

&lt;p&gt;LiPi is built in Streamlit and deployed as a &lt;a href="https://huggingface.co/spaces/AmanSNP/postcard-yolo-app" rel="noopener noreferrer"&gt;public Hugging Face Space&lt;/a&gt;, so anyone can try it in a browser without setting anything up.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 Default and Advanced modes
&lt;/h3&gt;

&lt;p&gt;A radio button in the sidebar switches modes. Default shows a summary of the recommended settings and needs no configuration. Advanced exposes the full pipeline: a weights selector (postcard detector v1 or v2, base YOLO11s, base YOLO26n or a custom path), the confidence threshold, the rectifier mode (Hybrid, Learned, Contour or Off), the learned rectifier model, manual rotation, the OCR backend and the postcode lookup country. Both sidebars are shown in Appendix A.5.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 Upload or camera
&lt;/h3&gt;

&lt;p&gt;The main area takes an image through two tabs: drag and drop or browse for a PNG, JPG, JPEG or WEBP file, or turn on the webcam and take a photo. The camera tab is the first image at the top of this report, and the upload tab is in Appendix A.5.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.3 Reading the results
&lt;/h3&gt;

&lt;p&gt;Once an image is in, four tiles show whether the postcard was rectified, how many regions YOLO found, the predicted postcode and the chosen orientation. Below them, the visual check tab shows a two by two grid of the original image, the detected postcard boundary, the straightened input and the YOLO boxes. The second image at the top of this report is a complete result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/try-it.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcrikvyq0noj4r96zxou.jpg" alt="Try it yourself panel from our poster: a QR code, the app on a phone and a laptop, and three steps to choose a model, upload a picture and see the result" width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The "Try it yourself" panel from our showcase poster.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Mostly synthetic data.&lt;/strong&gt; Apart from the 110 real postcards, everything is generated, which limits how well the model generalises to irregular handwriting, damaged cards, unusual stamp placement or non-standard layouts.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A weak class.&lt;/strong&gt; None of the real handwritten postcards include a date note, which is why &lt;code&gt;date_note&lt;/code&gt; has the lowest normalised recall (0.67) and mAP50-95 (0.896).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;OCR on hard crops.&lt;/strong&gt; Accuracy drops on very low-resolution crops and heavily stylised handwriting. The Gemini or Groq VLM fallback helps, but it can still fail on difficult perspectives.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Rectifier failures.&lt;/strong&gt; The contour rectifier sometimes fails on postcards photographed at extreme angles or against cluttered, high-contrast backgrounds.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Scope.&lt;/strong&gt; LiPi only supports four-digit Australian postcodes and a fixed set of ten regions.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;No OCR benchmark.&lt;/strong&gt; We measured region detection, not end-to-end postcode reading. Without an exact-match table, we can't say how often LiPi reads the postcode correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/failed-camera.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhnmtw6pu0jelugpbet77.jpg" alt="A failed camera prediction where a postcard on a busy patterned background is not straightened correctly" width="421" height="751"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A failure case: a cluttered background confuses the rectifier.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Showcase and People's Choice
&lt;/h2&gt;

&lt;p&gt;On 24 June we set up at stand 1 of the AI Showcase with the arm, a stack of postcards and the demo running on the big screen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=bnlpxd189n4" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy52ika6o8i5zgbgkfk83.jpg" alt="Video: LiPi Sort mini vlog" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our mini vlog from building LiPi Sort and showcase day.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At the Tech Fest awards night, LiPi won the &lt;strong&gt;AI Showcase People's Choice Award&lt;/strong&gt; from the UTS Faculty of Engineering and IT.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What's next
&lt;/h2&gt;

&lt;p&gt;On its own, the app turns sorting into three steps: click, read, place. We connected it to a robotic arm that takes the physical step too, which brings it down to one: place the card, and the arm puts it in the right tray.&lt;/p&gt;

&lt;p&gt;Beyond that, the report points to three pieces of future work: expanding the real handwritten dataset to cover all ten classes, fine-tuning a dedicated OCR model for handwritten postcodes, and deploying LiPi as an API or on an edge device like a Raspberry Pi so it can slot into a real mailroom.&lt;/p&gt;

&lt;p&gt;The bigger takeaway is that synthetic data generation is a viable, privacy-preserving way to build postal document datasets, and that a modern single-stage detector can reach near-perfect localisation on structured documents with modest training resources, at least on our validation split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledgements
&lt;/h2&gt;

&lt;p&gt;Thank you to Associate Professor Nabin Sharma, who taught the subject LiPi was built for and guided the project along the way. His work on detecting pincodes with deep CNNs for postal automation [5] is close to what LiPi does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;p&gt;The full diagnostics behind section 5, and the app screenshots from section 6. All detection metrics here are on the 124-image validation split and measure region detection, not postcode reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  A.1 Per-class detection metrics
&lt;/h3&gt;

&lt;p&gt;Validation performance per class. These metrics measure how well the model locates and classifies regions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;mAP50&lt;/th&gt;
&lt;th&gt;mAP50-95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All classes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;brand_logo&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;barcode&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;service_title&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stamp_imprint&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;publisher_imprint&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;date_note&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.896&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;message_block&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;address_block&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postcode_line&lt;/td&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postmark&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkublydbjkbkbmsrmpsu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkublydbjkbkbmsrmpsu7.png" alt="Per-class validation mAP50-95. Eight classes score 0.995. postcode\_line scores 0.93 and date\_note scores 0.896." width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Per-class mAP50-95 on validation. Only date_note and postcode_line fall below 0.995.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A.2 Confusion matrices
&lt;/h3&gt;

&lt;p&gt;Both matrices are almost perfectly diagonal. The exception is &lt;code&gt;date_note&lt;/code&gt;, at 0.67 on the normalised matrix.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/confusion-matrix.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft2w4s26q63m6wo6asqp2.jpg" alt="Confusion matrix of predicted against true classes, strongly diagonal" width="655" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Confusion matrix.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/confusion-matrix-normalised.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firnepiak13mkpc81681v.jpg" alt="Normalised confusion matrix with 1.00 on the diagonal for every class except date\_note at 0.67" width="646" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Normalised confusion matrix.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A.3 Training curves
&lt;/h3&gt;

&lt;p&gt;Box, classification and distribution focal losses fall steadily on both training and validation, while precision, recall and mAP climb and level off. Across all classes, F1 peaks at 0.97 at a confidence of about 0.375. On the precision-recall curve, every class reaches an mAP50 of 0.995, with &lt;code&gt;date_note&lt;/code&gt; losing some precision at low confidence thresholds because of its size.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/training-results.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F32me33ehsx3fef3ry2rx.jpg" alt="Training and validation loss curves falling and precision, recall, mAP50 and mAP50-95 curves rising" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Training and validation losses and metrics.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/f1-curve.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhblmw4rfwxxvt6fr5jj2.jpg" alt="F1 against confidence curve, all classes peaking at 0.97 at 0.375 confidence" width="679" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;F1 against confidence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/pr-curve.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6bvj0c2c7mzystdk87qf.jpg" alt="Precision-recall curve for every class" width="688" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Precision against recall.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A.4 Validation predictions
&lt;/h3&gt;

&lt;p&gt;Side by side, the model's predictions on validation postcards line up closely with the hand-labelled ground truth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/val-labels.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1w7zc3fermvp3vbqgkq8.jpg" alt="Grid of validation postcards with ground-truth labels" width="597" height="705"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ground-truth labels.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/val-predictions.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhbvw853upja9dijf005.jpg" alt="The same validation postcards with predicted boxes and confidence scores" width="600" height="703"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Model predictions.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A.5 App screenshots
&lt;/h3&gt;

&lt;p&gt;The two sidebar modes from section 6.1 and the upload tab from section 6.2.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/gui-default.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfr6b29t0jeukr059rnr.jpg" alt="LiPi sidebar in Default mode showing the recommended production settings" width="268" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Default mode.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/gui-advanced.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ipqry28khuqo504ruqv.jpg" alt="LiPi sidebar in Advanced mode with weights, confidence threshold, rectifier and rotation controls" width="319" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Advanced mode.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/lipisort/report/gui-upload.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tl1pdvdpdvam0eas2ec.jpg" alt="LiPi upload tab with the heading Detect, straighten and read postcards" width="385" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Uploading a file.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When the real data is private, synthetic postcards can still teach a detector to find the postcode on real handwritten cards.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Ngu, E. O. Y., &amp;amp; Ali, S. H. A. (2017). Development of handwriting recognition system in postal service sector. &lt;a href="https://jtec.utem.edu.my/jtec/article/view/3085" rel="noopener noreferrer"&gt;jtec.utem.edu.my&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Blumenstein, M., &amp;amp; Verma, B. (1998). A Neural Network for Real-World Postal Address Recognition. In &lt;em&gt;Soft Computing in Engineering Design and Manufacturing&lt;/em&gt; (pp. 79 to 83). &lt;a href="https://doi.org/10.1007/978-1-4471-0427-8_9" rel="noopener noreferrer"&gt;doi.org/10.1007/978-1-4471-0427-8_9&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Garrido-Munoz, C., &amp;amp; Calvo-Zaragoza, J. On the Generalization of Handwritten Text Recognition Models. CVPR 2025. &lt;a href="https://openaccess.thecvf.com/content/CVPR2025/papers/Garrido-Munoz_On_the_Generalization_of_Handwritten_Text_Recognition_Models_CVPR_2025_paper.pdf" rel="noopener noreferrer"&gt;openaccess.thecvf.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Malik, H. N., Shafi, K. M., &amp;amp; Reshi, T. A. (2026). synthocr-gen: A synthetic OCR dataset generator for low-resource languages. arXiv. &lt;a href="https://doi.org/10.48550/arxiv.2601.16113" rel="noopener noreferrer"&gt;doi.org/10.48550/arxiv.2601.16113&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Sharma, N., Sengupta, A., Sharma, R., Pal, U., &amp;amp; Blumenstein, M. (2017). Pincode detection using deep CNN for postal automation. In &lt;em&gt;2017 International Conference on Image and Vision Computing New Zealand (IVCNZ)&lt;/em&gt; (pp. 1 to 6). IEEE. &lt;a href="https://doi.org/10.1109/IVCNZ.2017.8402501" rel="noopener noreferrer"&gt;doi.org/10.1109/IVCNZ.2017.8402501&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• &lt;a href="https://huggingface.co/spaces/AmanSNP/postcard-yolo-app" rel="noopener noreferrer"&gt;Try LiPi on Hugging Face&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://www.youtube.com/watch?v=5NoXNYZFsEk" rel="noopener noreferrer"&gt;Demo video on YouTube&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://youtu.be/bnlpxd189n4" rel="noopener noreferrer"&gt;Mini vlog on YouTube&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://utsfeitshowcases.org/ai-showcase/" rel="noopener noreferrer"&gt;UTS FEIT AI Showcase&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://www.uts.edu.au/events/uts-tech-festival" rel="noopener noreferrer"&gt;UTS Tech Festival&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was first published at &lt;a href="https://amansinghnp.com/writing/lipisort-deeplearning" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>computervision</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>robotics</category>
    </item>
    <item>
      <title>Inside the 2026 Canvas breach</title>
      <dc:creator>Aman Singh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:35:36 +0000</pubDate>
      <link>https://dev.to/amansinghnp/inside-the-2026-canvas-breach-278e</link>
      <guid>https://dev.to/amansinghnp/inside-the-2026-canvas-breach-278e</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amansinghnp.com/writing/canvas-breach" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 2 May 2026 Instructure said the breach of Canvas, its learning platform, was contained. Five days later the attacker was back in through a second bug of the same kind, and roughly 300 organisations found a ransom note on their Canvas login page [1]. The first hole had been closed. A second one, in the discussions feature, was still open [1].&lt;/p&gt;

&lt;p&gt;For UTS Tech Fest 2026, two teammates and I took the incident apart. UTS was among the universities that issued statements about it [10], so this was close to home. We rebuilt the timeline, mapped each step to MITRE ATT&amp;amp;CK, and wrote the response plan we think Instructure should have followed, using the Australian Cyber Security Centre's guidance and the rules an Australian company would have to report under.&lt;/p&gt;

&lt;p&gt;This post is that project, updated with what Instructure has since published about the root cause and the data that was taken. It starts with the response, because that is the part we think went most wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read this&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Claimed.&lt;/strong&gt; Said by ShinyHunters, the group behind the attack, and not confirmed by Instructure.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Reported.&lt;/strong&gt; Stated by Instructure, a government body or a named publication, with the source cited. Instructure's statements are its own account, not an independent finding.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Our analysis.&lt;/strong&gt; Our reading, reconstruction or recommendation. The ATT&amp;amp;CK mapping and the response plan are ours, not Instructure's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;275M&lt;/strong&gt; users' data, claimed&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8,809&lt;/strong&gt; institutions, reported&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.65 TB&lt;/strong&gt; of data, claimed&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~300&lt;/strong&gt; organisations shown the ransom note, reported&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 days&lt;/strong&gt; from "contained" to back in, reported dates&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;25&lt;/strong&gt; AU and NZ universities hit, reported&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the response went wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; Instructure detected the activity on 29 April and revoked the attacker's access [4,6]. It disclosed the incident on its status page on 1 May, and on 2 May said the issue was contained [4].&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; On 7 May the attacker regained access through a second stored XSS bug, in the discussions feature. Instructure says that bug was still unpatched, and that the attacker got around the earlier fix by using the OAuth flow to generate a token [1]. The attacker then pushed CSS through Canvas's custom themes feature, which put a ransom note in front of roughly 300 organisations [1,3,5]. Students were posting screenshots on Reddit by the early afternoon, US time, and Instructure took Canvas down, during finals for many institutions [1,4]. Instructure says it detected and disabled this second attack about 10 minutes after it began, and has found no evidence that data was taken on 7 May [1].&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/cybersecurity/slide2.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gy667jk4hk215y4m4ip.jpg" alt="ShinyHunters ransom note saying they had breached Instructure again and giving affected schools until 12 May 2026 to negotiate before their data was leaked" width="800" height="994"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The ransom note from 7 May, with the deadline pushed to 12 May.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; On 11 May, a day before the attacker's new deadline, Instructure said it had reached an agreement with the attacker, apologised for the lack of transparency on its incident page, and said it had been given evidence that copies of the stolen data were deleted [1,4,6]. In Australia the announcement landed on 12 May, local time [6]. A proposed class action was filed in the US District Court for the Southern District of California on 13 May [4].&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; Instructure has not announced a payment. Its customer FAQ does, in an undated entry, answer the question of why it "decided to pay the ransom", defending the decision without disputing the premise or giving an amount [1]. A figure of US$10 million has circulated only as an unconfirmed rumour [4].&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our analysis.&lt;/strong&gt; Three things went wrong in the response, and none of them needed new technology to avoid. "Contained" was declared on the strength of one fix while a second bug of the same class was still open. The public account stayed thin enough that Instructure ended up apologising for it. And the payment has been acknowledged by implication rather than stated, which leaves schools and students to guess what was agreed on their behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they got in
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Instructure has confirmed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; Instructure's incident page sets out the root cause, though that explanation carries no publication date [1]. It started with a free account. Canvas ran a Free-For-Teacher programme whose accounts could be created without institutional verification. Bitdefender's analysis is that those accounts shared infrastructure with paying institutions [2].&lt;/p&gt;

&lt;p&gt;On 22 April the attacker used one of those accounts to lodge a support ticket with what Instructure called "a linked file with hidden code" [3]. On 25 April a customer service agent opened it, and the code ran inside the agent's logged-in session. This is stored cross-site scripting (XSS): the script is saved on the server and runs later, in someone else's session. Instructure says it gave the attacker an authorization token and elevated access within Canvas [1].&lt;/p&gt;

&lt;p&gt;Between 28 and 30 April the attacker used that access to pull data out through Canvas's APIs: usernames, email addresses, course names, enrolment information and messages [1,3]. Instructure says the messages are still under forensic review, and that none of the data fields involved were designed to hold passwords, dates of birth, financial details or grades [1]. On 21 May it gave Canvas administrators preliminary findings on which data fields were taken, and on 26 July it began delivering the exfiltrated user and provisioning data to each affected institution's named security contacts [1].&lt;/p&gt;

&lt;h3&gt;
  
  
  What ShinyHunters claimed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Claimed.&lt;/strong&gt; ShinyHunters posted a ransom note on 3 May claiming the breach [4], with a deadline of 7 May that it later moved to 12 May [2]. It said it had taken 3.65 TB of data on about 275 million users across nearly 9,000 schools [4]. Instructure has not confirmed these figures [2]. The count of 8,809 affected institutions comes from Reuters, as cited by Wikipedia, not from the attackers [4].&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3qbr0ijohu4ivq996at.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc3qbr0ijohu4ivq996at.png" alt="Attack chain. 22 April: a Free-For-Teacher account lodges a support ticket with hidden code. 25 April: a customer service agent opens it, stored XSS runs in their session and an authorization token is taken. 28 to 30 April: data is pulled out through the API. 2 May: Instructure declares the incident contained. 3 May: ransom demand. 7 May: a second, still-open XSS bug is used to deface about 300 login pages. 11 May: an agreement is announced." width="800" height="1125"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our reconstruction of the attack chain from the reported dates. The 275M figure is ShinyHunters' claim. Instructure called it contained on 2 May; five days later the attacker came back through a second XSS bug.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Day by day
&lt;/h2&gt;

&lt;p&gt;The Source column says whose account each row rests on. The technique IDs are our mapping to MITRE ATT&amp;amp;CK Enterprise [7], not Instructure's.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;ATT&amp;amp;CK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;22 Apr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free-For-Teacher account lodges a support ticket with hidden code&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;T1585&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;25 Apr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent opens the ticket, stored XSS runs in their session&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;T1059.007&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Authorization token taken, elevated access&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;T1528, T1550.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;28–30 Apr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data pulled out through the API&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;T1213&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;29 Apr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Instructure detects it and revokes access&lt;/td&gt;
&lt;td&gt;Reported&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incident disclosed&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Instructure says the issue is contained&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ShinyHunters claims the breach and sets a 7 May ransom deadline&lt;/td&gt;
&lt;td&gt;Claimed&lt;/td&gt;
&lt;td&gt;T1657&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;7 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Second XSS bug, ransom note shown to ~300 organisations, Canvas taken down&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;T1059.007, T1491.002&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Ransom deadline moved to 12 May&lt;/td&gt;
&lt;td&gt;Claimed&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;11 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agreement announced&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;13 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proposed class action filed&lt;/td&gt;
&lt;td&gt;Reported&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;21 May&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Preliminary findings on the data fields taken sent to Canvas administrators&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;26 Jul&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exfiltrated data starts going to institutions' security contacts&lt;/td&gt;
&lt;td&gt;Instructure&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why it worked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Our analysis.&lt;/strong&gt; There was no clever zero-day here. XSS has been in the OWASP Top 10 for over a decade. What made it so costly was four gaps sitting on top of each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Unverified accounts on shared infrastructure.&lt;/strong&gt; A Free-For-Teacher account needed no institutional verification, and by Bitdefender's analysis it shared infrastructure with paying institutions [2].&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Untrusted content opened in a trusted tool.&lt;/strong&gt; A ticket from an anonymous user was rendered inside a staff session. Scott Helme, who infers that the session could reach across customers, calls the ticket "the back door to your admin console" [3].&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;One token was enough.&lt;/strong&gt; A single token lifted from a browser gave elevated access inside Canvas [1]. On Reuters' count, data from 8,809 institutions followed [4].&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;"Contained" before the bug class was fixed.&lt;/strong&gt; The first XSS bug was closed. A second one, in discussions, was not [1]. Helme argues a strict, nonce-based Content Security Policy could plausibly have stopped both scripts from running, while saying this is analysis rather than a tested result, and that CSP would not necessarily have stopped the CSS defacement [3].&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we'd have done
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Our analysis.&lt;/strong&gt; We built our plan on the ACSC's incident response guidance [8] and split it into six phases. Everything in this section is our recommendation, not a description of what Instructure did. The dashed box is where the real response went wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxaygbjkp0m7rwy1step.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxaygbjkp0m7rwy1step.png" alt="Six response phases in a loop: prepare, identify, protect, respond, recover, learn, then back to prepare. Instructure declared the incident contained during the respond phase before the second XSS bug was fixed." width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our response plan as a loop. Lessons from the last phase feed back into preparation.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Prepare
&lt;/h3&gt;

&lt;p&gt;Treat anything a free user submits as hostile. Clean user HTML with an allowlist sanitiser like DOMPurify, and set a strict Content Security Policy so an injected script doesn't run even when the filter misses it. Open support tickets in a sandboxed viewer, not in a session that can touch customer data. Put the free tier on its own infrastructure, and keep tokens short-lived: eight hours at most, with keys rotated every 30 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Identify
&lt;/h3&gt;

&lt;p&gt;Alert on bulk API reads, logins from new locations and off-hours activity. Flag any token that touches more than one tenant, or that's been in use for eight hours without re-authenticating. Collect CSP violation reports too. A blocked script inside a staff session is a loud signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Protect
&lt;/h3&gt;

&lt;p&gt;Once the intrusion is confirmed, revoke every admin token, API key and session, not just the ones you know were used. Freeze Free-For-Teacher signups, throttle outbound data, and put admin settings, themes included, behind break-glass access.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Respond
&lt;/h3&gt;

&lt;p&gt;Hunt for the whole bug class before saying "contained". Instructure fixed one XSS bug and missed a second, and the attacker was back through it in five days. Bring in forensics early, keep the logs, report to the ASD's ACSC, and have one person speak for the company.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Recover
&lt;/h3&gt;

&lt;p&gt;Restore from backups taken before 25 April, rebuild login pages and themes from a known-good copy, and reset every admin and service credential. Bring services back in stages, least sensitive first, with monitoring turned up.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Learn
&lt;/h3&gt;

&lt;p&gt;Run a blameless review within 30 days. Give every XSS fix a named owner and a deadline, and write what you learn into the runbooks and drills. Treat free-tier users as untrusted until they're verified.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Instructure says it has changed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; Instructure's incident page lists the changes it has made, mostly without dates. It has permanently discontinued Free-For-Teacher, restricted administrative access to trusted locations, locked down sensitive admin paths and tightened API access, rotated API keys and authentication tokens, and cycled all employee sessions and internal access tokens. It is also rolling out enforced token expiry and an end to lifetime tokens [1]. Former Free-For-Teacher users were given two short windows, 28–29 May and 28–29 July, to download their materials [1].&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our analysis.&lt;/strong&gt; Most of that list lines up with our Prepare and Protect phases. The page does not mention a Content Security Policy, and it describes fixing the vulnerabilities that were used rather than a sweep for the whole bug class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Australian side
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reported.&lt;/strong&gt; Instructure is American, but a lot of the data wasn't. The University of Canberra said 25 universities in Australia and New Zealand were affected [9]. On 7 May the ABC reported that Queensland and Tasmanian state schools were affected, and that UTS, Sydney, Melbourne, Western Sydney, Newcastle and Flinders had issued statements saying they were aware of the incident [10]. The National Cyber Security Coordinator, Lt Gen Michelle McGuinness, coordinated the government's response and warned of a higher risk of scams and phishing aimed at students and staff [6,10].&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our analysis.&lt;/strong&gt; If a company operating under Australian law had been in Instructure's position, these rules would apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Notifiable Data Breaches scheme.&lt;/strong&gt; Under the Privacy Act 1988, a suspected breach has to be assessed within 30 days. If it's likely to cause serious harm, the OAIC and the affected people must be told [11,12].&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Ransomware payment reporting.&lt;/strong&gt; Since 30 May 2025, a business with turnover above $3 million that makes a payment, or gives any other benefit, in response to a cyber extortion demand must report it to the Australian Signals Directorate within 72 hours [13]. An "agreement" that involved a payment would count.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Where students can complain.&lt;/strong&gt; The OAIC's statement of 8 May, updated on 11 May, says affected people must first complain to Instructure or their school and allow 30 days for a response. Many public universities and state schools fall under state privacy law rather than the Privacy Act [14].&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Presenting it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/cybersecurity/slide4.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwihshvz445pulk8q2i0p.jpg" alt="Slide showing the Canvas breach timeline from initial access on 25 April to the agreement on 11 May, with MITRE ATT&amp;amp;CK technique IDs and the scope of impact" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our timeline slide.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amansinghnp.com/assets/cybersecurity/slide3.jpg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxe8jcq2hef8mnwhzjtt.jpg" alt="Slide titled What Instructure Should Have Done, with six incident response phases: prepare, identify, protect, respond, recover and lessons learned" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our response plan slide.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This project reignited my curiosity for cybersecurity. It confirmed that threat intelligence and building systems that hold up under attack is where I want to spend my engineering career."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One support ticket from a free account opened the way to what ShinyHunters claims is 275 million users' data, and a fix declared too early let them back in. A breach is a chain, and you stop it at the weakest link.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Instructure. Security incident update and FAQs for customers. Most entries undated; dated status updates on 20 May and 27 July 2026. &lt;a href="https://www.instructure.com/incident-update/customers" rel="noopener noreferrer"&gt;instructure.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Bitdefender. Technical advisory: ShinyHunters breach of Instructure Canvas LMS. 8 May 2026. &lt;a href="https://businessinsights.bitdefender.com/technical-advisory-shinyhunters-breach-instructure-canvas-lms" rel="noopener noreferrer"&gt;businessinsights.bitdefender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Helme, S. The Instructure Canvas breach (2026): how XSS in a support ticket compromised 275 million students. 15 June 2026. &lt;a href="https://scotthelme.ghost.io/the-instructure-canvas-breach-2026-how-xss-in-a-support-ticket-compromised-275-million-students/" rel="noopener noreferrer"&gt;scotthelme.ghost.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Wikipedia. 2026 Canvas data breach. &lt;a href="https://en.wikipedia.org/wiki/2026_Canvas_data_breach" rel="noopener noreferrer"&gt;en.wikipedia.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Malwarebytes. ShinyHunters escalates Canvas attacks with school login defacements. 8 May 2026. &lt;a href="https://www.malwarebytes.com/blog/news/2026/05/shinyhunters-escalates-canvas-attacks-with-school-login-defacements" rel="noopener noreferrer"&gt;malwarebytes.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Information Age (ACS). Canvas breach: "agreement" reached with hackers over stolen data. 12 May 2026. &lt;a href="https://ia.acs.org.au/article/2026/canvas-breach---agreement--reached-with-hackers-over-stolen-data.html" rel="noopener noreferrer"&gt;ia.acs.org.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; MITRE. ATT&amp;amp;CK Enterprise matrix. &lt;a href="https://attack.mitre.org/matrices/enterprise/" rel="noopener noreferrer"&gt;attack.mitre.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Australian Signals Directorate. Cybersecurity incident response planning: practitioner guidance. December 2024. &lt;a href="https://www.cyber.gov.au/sites/default/files/2025-03/Cybersecurity%20incident%20response%20planning%20%E2%80%93%20Practitioner%20guidance%20(December%202024).pdf" rel="noopener noreferrer"&gt;cyber.gov.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; University of Canberra. Australia-wide Canvas learning management platform breach. 6 May 2026. &lt;a href="https://www.canberra.edu.au/about-uc/media/media-releases/2026/may/australia-wide-canvas-learning-management-platform-breach" rel="noopener noreferrer"&gt;canberra.edu.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; ABC News. Canvas data breach leaves education providers scrambling as student data compromised. 7 May 2026, updated the same day. &lt;a href="https://www.abc.net.au/news/2026-05-07/canvas-data-breach-instructure/106651234" rel="noopener noreferrer"&gt;abc.net.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; Privacy Act 1988 (Cth), Part IIIC. &lt;a href="https://www.legislation.gov.au/C2004A03712/latest/text" rel="noopener noreferrer"&gt;legislation.gov.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; OAIC. About the Notifiable Data Breaches scheme. &lt;a href="https://www.oaic.gov.au/privacy/notifiable-data-breaches/about-the-notifiable-data-breaches-scheme" rel="noopener noreferrer"&gt;oaic.gov.au&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; MinterEllison. Mandatory ransomware payment reporting obligations now in force. 16 June 2025. &lt;a href="https://www.minterellison.com/articles/mandatory-ransomware-payment-reporting-obligations-in-force" rel="noopener noreferrer"&gt;minterellison.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt; OAIC. Statement on Instructure (Canvas) cyber incident. 8 May 2026, updated 11 May 2026. &lt;a href="https://www.oaic.gov.au/news/media-centre/statement-on-instructure-canvas-cyber-incident" rel="noopener noreferrer"&gt;oaic.gov.au&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This article was first published at &lt;a href="https://amansinghnp.com/writing/canvas-breach" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>incidentresponse</category>
    </item>
    <item>
      <title>Memora: a Slack agent for teams that forget</title>
      <dc:creator>Aman Singh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:35:17 +0000</pubDate>
      <link>https://dev.to/amansinghnp/memora-a-slack-agent-for-teams-that-forget-2m83</link>
      <guid>https://dev.to/amansinghnp/memora-a-slack-agent-for-teams-that-forget-2m83</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amansinghnp.com/writing/memora-slack-agent" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Memora started with a problem I kept running into. I've been President of AusLeap since March 2025 and an Associate at TechSoc UTS, and both clubs hand over to a new committee every year. The reasons behind last year's decisions leave with the people who made them. Why a venue was picked, why the budget was capped where it was: the answer is in a Slack thread or a Google Doc somewhere, and the new committee works it all out again from scratch. Clubs are the extreme case, but every team that loses a person does the same.&lt;/p&gt;

&lt;p&gt;Two teammates and I built Memora for the &lt;strong&gt;Slack Agent Builder Challenge&lt;/strong&gt; (4,426 participants). It lives in Slack, keeps a record of what the team decided, and answers questions about it later with a source and a confidence level.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Almost everything runs through a handful of slash commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;/decide&lt;/code&gt; records a decision. The text is split into sentence-aware chunks, embedded and stored so it can be found later.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/ask&lt;/code&gt; answers a question from the team's memory: recorded decisions plus any connected Google Docs and Sheets.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/connect-folder&lt;/code&gt; links a Google Drive folder. Its Docs and Sheets are ingested and kept in sync. &lt;code&gt;/disconnect-folder&lt;/code&gt; takes it away again.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/register&lt;/code&gt; links your Slack account to a Google account, and &lt;code&gt;/unregister&lt;/code&gt; removes the link.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/reconcile-run&lt;/code&gt; starts a reconciliation run and posts what it finds to a review channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I built
&lt;/h2&gt;

&lt;p&gt;We split the work by area. Mine was the Google Drive side and the approval half of reconciliation. I wrote the Google Docs ingestion pipeline, the connected-folder sync behind &lt;code&gt;/connect-folder&lt;/code&gt; and &lt;code&gt;/disconnect-folder&lt;/code&gt;, the worker that polls Drive for changes, and &lt;code&gt;/register&lt;/code&gt; and &lt;code&gt;/unregister&lt;/code&gt;. For reconciliation I built the proposal model: how a proposal is stored, which states it can move between, the 72 hour expiry, and the checkmark reaction that approves it. Near the end I also reviewed the pull requests that made Memora installable across multiple workspaces.&lt;/p&gt;

&lt;p&gt;My teammates built &lt;code&gt;/decide&lt;/code&gt; and the embeddings behind it, &lt;code&gt;/ask&lt;/code&gt; with its confidence levels and answer writing, Sheets ingestion, Slack message ingestion, and the reconciliation run that finds the mismatches in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answers that admit when they don't know
&lt;/h2&gt;

&lt;p&gt;The thing we cared about most was &lt;code&gt;/ask&lt;/code&gt; not making things up. It embeds the question, searches decisions and documents together, and throws away anything below a similarity threshold. Claude then writes the answer from whatever evidence is left and cites where it came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwdmdgqsyak81whkquqj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwdmdgqsyak81whkquqj.png" alt="How /ask answers: embed the question, run vector search over decisions, Docs and Sheets, drop weak matches below a similarity threshold, then Claude writes an answer with a citation and a High, Medium or Low confidence level." width="800" height="874"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What happens when someone runs &lt;code&gt;/ask&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every answer is tagged High, Medium or Low, with a one-line reason such as "Supported by a formal /decide statement." If nothing relevant gets past the threshold, Memora says Low and tells you it found nothing, rather than guessing. For a handover tool that matters more than anything else. A Low answer tells the next person to go and ask someone, which is exactly what they should do.&lt;/p&gt;

&lt;h2&gt;
  
  
  A person signs off on every change
&lt;/h2&gt;

&lt;p&gt;Reconciliation is where Memora looks across the team's records for things that no longer line up, like a budget in one sheet that doesn't match the handover doc. It's only useful if it can eventually act on what it finds, but an agent quietly rewriting a budget is the fastest way to lose a team's trust. So it doesn't fix anything by itself. Each finding is saved as a proposal with its source evidence and a suggested action, then posted to the review channel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43r7fbexlhf1m47hmw00.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43r7fbexlhf1m47hmw00.png" alt="Reconciliation: /reconcile-run creates a pending proposal with evidence and a suggested action in the review channel. It ends as confirmed when an approver reacts, rejected, expired after 72 hours, or superseded by a newer run." width="800" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every finding becomes a proposal that a person has to approve.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An approver confirms a proposal by reacting to it with a checkmark. Anything nobody acts on expires after 72 hours, and a newer run can replace an older one. Nothing gets written back until someone has said yes, and every step is kept in an audit trail. It adds a step, but it's the step that makes the rest believable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we found hard
&lt;/h2&gt;

&lt;p&gt;Building it was the easy part to describe. Here's what actually slowed us down, and what we did about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three countries, two clocks
&lt;/h3&gt;

&lt;p&gt;One teammate was in Vancouver, one was on the US West Coast, and I was in Sydney. Vancouver and the West Coast share a timezone, so the two of them could work together in real time. I was 17 to 19 hours ahead, depending on daylight saving. That left a window of two to four hours where all three of us were awake and working.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firaxh46fkg9fcql93rcq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firaxh46fkg9fcql93rcq.png" alt="Working hours in Sydney time. My day ran 9am to 6pm. My teammates' 9am to 6pm on the Pacific coast fell between 2am and 11am Sydney time, so we overlapped for about two hours, from 9am to 11am Sydney time." width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A standard working day on both sides, with a 17 hour gap (Sydney on AEST, the West Coast on PDT). Their Monday afternoon was my Tuesday morning. When daylight saving pushes the gap to 19 hours, the overlap stretches to about four.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We stopped fighting the gap and started using it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;One short call in the overlap.&lt;/strong&gt; We saved the shared window for talking things through together and kept everything else async.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Written updates in Slack.&lt;/strong&gt; Before logging off, each of us posted what got done, what was blocked and what was next, so whoever woke up could pick it straight up.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;One area each.&lt;/strong&gt; Each of us owned a separate part of the system, so most days nobody was stuck waiting on someone who was asleep.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Reviews overnight.&lt;/strong&gt; Open a pull request before bed and wake up to a review. The code kept moving while each of us slept.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was a little on the nose. We were building a tool for teams that lose context, and what held our own team together was writing things down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping Google Drive in sync
&lt;/h3&gt;

&lt;p&gt;A connected folder changes all the time. Re-reading everything on every change is slow and wasteful, but missing a change means &lt;code&gt;/ask&lt;/code&gt; answers from a stale document. The worker now polls Drive's Changes API every five minutes by default. A change points to the connected folders it affects, those get rescanned, and only files whose &lt;code&gt;modifiedTime&lt;/code&gt; actually moved are ingested again.&lt;/p&gt;

&lt;p&gt;Removal needed the same care. When a team disconnects a folder, Memora purges the documents that no other connected folder still references, so the memory doesn't hang on to things the team took away. On the Slack side, the daily walk catches any edits and deletions the bot missed in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making three services feel like one
&lt;/h3&gt;

&lt;p&gt;Splitting the work by area solved the timezone problem and created a new one. Memora runs as three services (the Slack bot, an ingestion API and a Drive sync worker), and their parts were written separately by people who were rarely online at the same time. The seams were where things broke. What helped was keeping the contract between them small. Everything ends up as document rows in Supabase, tagged with where it came from (a decision from &lt;code&gt;/decide&lt;/code&gt; is stored with the source &lt;code&gt;slack_decide&lt;/code&gt;), so each service only had to agree on that one shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;The biggest lesson matched the product. Context disappears unless someone deliberately writes it down, whether that's a club handing over to next year's committee or three people passing work across seventeen hours of time difference. Memora makes that the default for teams. Working across three countries made it the default for us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• &lt;a href="https://amansinghnp.com/projects/memora" rel="noopener noreferrer"&gt;The Memora case study&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://www.youtube.com/watch?v=MC_DX3BJOXI" rel="noopener noreferrer"&gt;Demo video on YouTube&lt;/a&gt;&lt;br&gt;&lt;br&gt;
• &lt;a href="https://devpost.com/software/memora-mel4uo" rel="noopener noreferrer"&gt;Devpost submission&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Teams rarely lose the decision. They lose the reason behind it. Record both.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This article was first published at &lt;a href="https://amansinghnp.com/writing/memora-slack-agent" rel="noopener noreferrer"&gt;amansinghnp.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>slack</category>
      <category>python</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
