{"version":"https://jsonfeed.org/version/1.1","title":"Tommaso Vaccari — Engineering Notes","home_page_url":"https://www.tommasovaccari.com","feed_url":"https://www.tommasovaccari.com/feed.json","description":"Software projects, engineering notes, and experiments in systems, algorithms, and machine learning.","language":"en","authors":[{"name":"Tommaso Vaccari"}],"icon":"https://www.tommasovaccari.com/icon-512.png","favicon":"https://www.tommasovaccari.com/favicon.ico","items":[{"id":"https://www.tommasovaccari.com/blog/integrating-vinardo-scoring-function-into-mudock","url":"https://www.tommasovaccari.com/blog/integrating-vinardo-scoring-function-into-mudock","title":"Integrating the Vinardo Scoring Function into muDock","summary":"A technical deep dive into integrating Vinardo into muDock, from molecular representation and preprocessing to CPU scoring, validation against smina, and CASF-2016 results.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h1>Introduction</h1>\n<p>This report presents the work I carried out to introduce a new component into the <a href=\"https://github.com/elvispolimi/muDock\" rel=\"noopener noreferrer\">muDock</a> codebase.</p>\n<p>The complete implementation is available in <a href=\"https://github.com/elvispolimi/muDock/pull/105\" rel=\"noopener noreferrer\">muDock PR #105</a>.</p>\n<h2>Context</h2>\n<p>As explained in section 2 of the <a href=\"https://arxiv.org/abs/2509.12232\" rel=\"noopener noreferrer\">muDock vectorization paper</a>, the general context of this work is drug discovery: the process through which candidate molecules are investigated as potential effective treatments for a disease. One of the initial phases of this process is virtual screening, where computational techniques are used to analyse a large set of candidate molecules and select the most promising ones before experimental validation in a laboratory. Virtual screening provides an initial filtering step, reducing the amount of work that must later be performed in the laboratory and therefore reducing time and costs.</p>\n<p>Molecular docking is one of the techniques used in virtual screening. Given a receptor, typically a protein, and a ligand, meaning a candidate molecule, the objective is to estimate how the ligand may arrange itself inside an interaction region of the protein called the binding site. The quality, and therefore the probability, of an arrangement is evaluated through an energy term used as a proxy for binding affinity. A single spatial arrangement of the ligand is called a pose and includes the position, orientation, and internal torsions of the molecule.</p>\n<p>To compare poses, docking uses a scoring function: a function that assigns a numerical value to each configuration, approximating the quality of the interaction between the ligand and the receptor. The task of docking is therefore to explore the pose space and identify the poses associated with the most favourable scores.</p>\n<p>The computational problem is divided into two levels. The first concerns the number of candidate molecules to evaluate, which can generally be very large. The second concerns the number of possible poses to explore for each receptor-ligand pair. The degrees of freedom of the complex come from the ligand being able to move in space and rotate internally. In general, an exhaustive search over the pose space is not practical.</p>\n<p>For this reason, docking engines usually use heuristics and optimisation algorithms that seek a compromise between computational cost and solution quality. In addition, many evaluations of different poses or molecules can be performed independently, making molecular docking a perfect use case for HPC.</p>\n<h2>Objective</h2>\n<p>The objective of the project was to implement a scoring function in C++ by using the existing infrastructure and pipeline of <a href=\"https://github.com/elvispolimi/muDock\" rel=\"noopener noreferrer\">muDock</a>. In particular, the implemented scoring function is Vinardo, described in the <a href=\"https://doi.org/10.1371/journal.pone.0155183\" rel=\"noopener noreferrer\">original paper</a>. This required adapting the data representation, atom typing, preprocessing of atom pairs, and score computation to the existing components.</p>\n<p>The main difficulty was therefore not only implementing the scoring function formula, but integrating it into an already structured codebase. The work required understanding the muDock architecture, comparing the behaviour with smina as the reference implementation, and introducing all the infrastructure required around the scoring kernel: atom typing, PDBQT topology information, pair preprocessing, and data management within the pipeline.</p>\n<h2>Project output</h2>\n<p>At the end of the project, the produced output was:</p>\n<ul>\n<li>C++ code integrated into the codebase;</li>\n<li>an execution pipeline for both scoring-only and scoring with a genetic algorithm;</li>\n<li>functional tests against the reference implementation.</li>\n</ul>\n<h1>Related Work and Background</h1>\n<p>This section explains at a high level the muDock architecture and the essential components for docking. It then explains the theoretical operation of Vinardo.</p>\n<h2>muDock architecture</h2>\n<p>muDock was created as a microapp for molecular docking, built to replicate an AutoDock-like workflow. In addition to providing a docking engine, the codebase is designed as an environment for experimenting with optimisation and porting techniques, such as vectorisation and support for different execution backends.</p>\n<p>In the context of this work, the most relevant aspect is the deliberate separation between the phases of the pipeline: input parsing, molecule representation, scoring, search, and output production. This organisation makes it possible to modify a single component, such as the scoring function, without rewriting the entire execution flow.</p>\n<p>At a high level, the execution flow is the following:</p>\n<ul>\n<li>the paths of the protein and ligand files are passed to the executable;</li>\n<li>the protein is parsed and loaded into memory as stable data because it is used to evaluate every ligand under analysis;</li>\n<li>ligands are progressively read, parsed, and inserted into a work queue;</li>\n<li>workers consume ligands from the queue, group them into batches, and execute the configured pipeline, which may consist of one or more stages;</li>\n<li>results are written into the ligand properties and produced as output.</li>\n</ul>\n<p><img src=\"https://www.tommasovaccari.com/static/high-level-flow-2a2290c9.webp\" alt=\"High-level execution flow\" /></p>\n<p>In general, a pipeline consists of one or more stages, where each stage represents a unit of work in the pipeline. In muDock, the stages relevant to this work are those related to scoring and genetic optimisation. Each stage follows a common cycle:</p>\n<ul>\n<li><code>prepare</code>: prepares the data required to execute the stage on the current batch;</li>\n<li><code>operator()</code>: performs the actual work;</li>\n<li><code>teardown</code>: collects the results and writes them into the output data structures.</li>\n</ul>\n<p>This structure can be seen as a stateful functor. The object maintains the state required for execution, such as references to buffers and scratchpads, and makes the computation invocable through <code>operator()</code>.</p>\n<p>For example, the scoring stage can be represented as follows:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>template</span><span>&lt;</span><span>typename</span><span> queue_type</span><span>&gt;</span></span>\n<span class=\"line\"><span>struct</span><span> scoring_stage</span><span> {</span></span>\n<span class=\"line\"><span>   std</span><span>::shared_ptr</span><span>&lt;</span><span>scratchpad</span><span>&lt;</span><span>queue_type</span><span>&gt;&gt;</span><span> scratch;</span></span>\n<span class=\"line\"><span>   void</span><span> prepare</span><span>(</span><span>batch</span><span>&lt;</span><span>static_molecule</span><span>&gt;</span><span>&amp;</span><span> batch</span><span>);</span></span>\n<span class=\"line\"><span>   void</span><span> operator</span><span>()</span><span>();</span></span>\n<span class=\"line\"><span>   void</span><span> teardown</span><span>(</span><span>batch</span><span>&lt;</span><span>static_molecule</span><span>&gt;</span><span>&amp;</span><span> batch</span><span>);</span></span>\n<span class=\"line\"><span>};</span></span></code></pre>\n<p>The available pipelines include a scoring-only pipeline, which calculates the score associated with the interaction between the protein and ligand in the given configuration, and a genetic pipeline, where the genetic stage uses the scoring function to evaluate the poses generated during the heuristic exploration of the pose space. In the second case, the parameters called <em>knobs</em> in the codebase control the number of individuals, number of generations, mutation probability, and other aspects of the search.</p>\n<p>At this point it is necessary to introduce the abstractions used to manage memory and data because this context requires enough flexibility to work independently from the execution backend and the framework used on that backend.</p>\n<p>muDock mainly uses two abstractions: <em>buffer</em> and <em>scratchpad</em>. A buffer represents a typed memory area that can be used to read and write data, but also to move it between the host, usually the machine launching the computation, and the device, the machine used to perform the intensive computation, for example one equipped with a GPU.</p>\n<p>The scratchpad is a shared registry of buffers. It allows stages to retrieve the data they need through common keys, avoiding unnecessary reconstruction or copying. The scratchpad can contain data that remains stable during the run, such as the representation of the protein, as well as data produced by one stage and consumed by the next. One example is the buffers associated with poses, containing ligand coordinates, which are produced during genetic exploration and then used by the scoring stage to evaluate the generated configurations.</p>\n<p>The fact that stages can pass data through shared buffers and key conventions is fundamental to guarantee stable contracts between different stages and compose them flexibly. This becomes clearer later, when the integration of the Vinardo-based scoring stage only requires implementing a stage that respects the input and output contracts, without modifying the genetic pipeline or the other existing scoring stages.</p>\n<p>The fundamental component of each stage is the kernel, which represents the actual numerical computation. The kernel is invoked through <code>operator()</code> and works on data already prepared by the previous phase. Since this is an HPC context and different execution backends must be supported, there can be different implementations of the same kernel, for example a CUDA version for GPU execution and a standard C++ version for CPU execution. For this work, the generic interface of the Vinardo kernel was implemented but specialised only for the CPU, leaving a GPU version as a possible future development.</p>\n<h2>Vinardo scoring function</h2>\n<p>Vinardo was developed as an evolution of the <a href=\"https://doi.org/10.1002/jcc.21334\" rel=\"noopener noreferrer\">AutoDock Vina scoring function</a>. For this reason, it preserves many of its characteristics and the same general structure. The quality of a pose is evaluated through a scoring function that sums energy contributions calculated over pairs of atoms.</p>\n<p>Before describing the scoring function, it is useful to distinguish between the conformation-dependent score, meaning the spatial arrangement, and the affinity value reported as an estimate of binding free energy. In the Vina model inherited by Vinardo, the conformation-dependent score is denoted by <code class=\"formula-inline\">C</code> and depends on the specific ligand pose.</p>\n<p>In particular, <code class=\"formula-inline\">C</code> consists of an intermolecular contribution associated with protein-ligand interactions and an intramolecular contribution associated with interactions internal to the ligand:</p>\n<div class=\"formula\"><code>C = C_{inter} + C_{intra}</code></div>\n<p>The value of <code class=\"formula-inline\">C</code> is calculated as the sum of the contributions over the atom pairs considered valid:</p>\n<div class=\"formula\"><code>C = \\sum_{(i,j) \\in \\mathcal{P}} F_{T_i,T_j}(d_{ij})</code></div>\n<p>where <code class=\"formula-inline\">\\mathcal{P}</code> represents the set of atom pairs selected as valid during preprocessing. The considered pairs are:</p>\n<ul>\n<li>protein-ligand pairs, obtained from the Cartesian product between the ligand atoms and protein atoms;</li>\n<li>ligand-ligand pairs, selected only when they satisfy the required topological conditions.</li>\n</ul>\n<p>In both cases, pairs containing at least one hydrogen atom are discarded. For ligand-ligand pairs, only atom pairs separated by more than three covalent bonds and able to move relative to each other are retained.</p>\n<p>The contribution associated with a single atom pair must then be defined. In the previous formula, <code class=\"formula-inline\">T_i</code> and <code class=\"formula-inline\">T_j</code> represent the atom types of the two atoms, while <code class=\"formula-inline\">d_{ij}</code> is the surface distance between the atoms in the pair. It is obtained by subtracting the radii associated with the two atom types from the Euclidean distance between the atomic centres:</p>\n<div class=\"formula\"><code>d_{ij} = r_{ij} - R_i - R_j</code></div>\n<p>The function <code class=\"formula-inline\">F_{T_i,T_j}</code> consists of multiple weighted energy terms:</p>\n<div class=\"formula\"><code>F_{T_i,T_j}(d) =\nw_1 \\cdot \\text{Gauss}(d) +\nw_2 \\cdot \\text{Repulsion}(d) +\nw_3 \\cdot \\text{Hydrophobic}(d) +\nw_4 \\cdot \\text{HBond}(d)</code></div>\n<p>The Gauss and Repulsion terms are evaluated for all pairs, while Hydrophobic and HBond depend on the atom types involved. In particular, the hydrophobic term is active only for pairs of hydrophobic atoms, while the hydrogen bond term is active when the pair may represent a donor-acceptor interaction.</p>\n<p>The individual terms are defined as follows:</p>\n<div class=\"formula\"><code>\\text{Gauss}(d) = e^{-\\left(\\frac{d - o_1}{s_1}\\right)^2}</code></div>\n<div class=\"formula\"><code>\\text{Repulsion}(d) =\n\\begin{cases}\nd^2 &amp; \\text{if } d &lt; 0 \\\\\n0 &amp; \\text{if } d \\geq 0\n\\end{cases}</code></div>\n<div class=\"formula\"><code>\\text{Hydrophobic}(d) =\n\\begin{cases}\n1 &amp; \\text{if } d \\leq p_1 \\\\\n\\dfrac{p_2 - d}{p_2 - p_1} &amp; \\text{if } p_1 &lt; d &lt; p_2 \\\\\n0 &amp; \\text{if } d \\geq p_2\n\\end{cases}</code></div>\n<div class=\"formula\"><code>\\text{HBond}(d) =\n\\begin{cases}\n1 &amp; \\text{if } d \\leq h_1 \\\\\n\\dfrac{d}{h_1} &amp; \\text{if } h_1 &lt; d &lt; 0 \\\\\n0 &amp; \\text{if } d \\geq 0\n\\end{cases}</code></div>\n<p>From an implementation perspective, the Hydrophobic and HBond terms are enabled through two flags precomputed during preprocessing. This means that scoring does not need to reevaluate the atomic properties of the pair and only has to apply the function to the prepared data.</p>\n<p>The weights used by Vinardo are those reported in the <a href=\"https://doi.org/10.1371/journal.pone.0155183\" rel=\"noopener noreferrer\">original paper</a>:</p>\n<div class=\"formula\"><code>w_{\\text{gauss}} = -0.045,\\quad\nw_{\\text{repulsion}} = 0.800,\\quad\nw_{\\text{hydrophobic}} = -0.035,\\quad\nw_{\\text{hbond}} = -0.600</code></div>\n<p>The main parameters of the terms are:</p>\n<div class=\"formula\"><code>o_1 = 0.0,\\quad\ns_1 = 0.8,\\quad\np_1 = 0.0,\\quad\np_2 = 2.5,\\quad\nh_1 = -0.6</code></div>\n<p>Once the conformation score <code class=\"formula-inline\">C</code> has been calculated for the poses of a ligand, it is converted into affinity through a function <code class=\"formula-inline\">g</code>, producing an estimate of the binding free energy.</p>\n<p>For the best pose:</p>\n<div class=\"formula\"><code>s_1 = g(C_1 - C_{intra,1}) = g(C_{inter,1})</code></div>\n<p>For subsequent poses, Vina instead subtracts the intramolecular contribution of the best pose to preserve the relative ranking:</p>\n<div class=\"formula\"><code>s_k = g(C_k - C_{intra,1})</code></div>\n<p>The Vina paper also proposes a concrete form for <code class=\"formula-inline\">g</code>, which was later inherited by Vinardo. The function normalises the value with respect to the number of rotatable bonds in the ligand:</p>\n<div class=\"formula\"><code>g(x) = \\frac{x}{1 + w_{rot} \\cdot N_{rot}}</code></div>\n<p>Operationally, <code class=\"formula-inline\">N_{rot}</code> refers to the number of rotatable bonds in the ligand. This value is not retrieved directly from the field in the PDBQT file, but reconstructed from an in-memory topological representation of the ligand structure. In particular, the rotors described by the PDBQT torsion tree are considered. A rotatable bond is counted only when both endpoint atoms are not hydrogens and each has at least two bonds with non-hydrogen atoms.</p>\n<p>From an implementation perspective, this structure therefore requires several components: atom typing compatible with Vinardo, a data representation suitable for scoring, a preprocessing phase to construct valid atom pairs, and a scoring function.</p>\n<h1>Implementation</h1>\n<p>This section describes the components implemented to integrate the new scoring function into the existing pipeline. The description follows the data flow: from the topological information extracted while parsing the PDBQT file, through the construction of the Vinardo view of the molecules, to atom-pair preprocessing and the scoring stage. During development, <a href=\"https://doi.org/10.1021/ci300604z\" rel=\"noopener noreferrer\">smina</a> was used as the reference implementation to compare atom typing, pair preprocessing, and scoring values. The final integration of the components described in this section is collected in <a href=\"https://github.com/elvispolimi/muDock/pull/105\" rel=\"noopener noreferrer\">PR #105</a>.</p>\n<h2>PDBQT data and topological representation of the ligand</h2>\n<p>The first required modification concerned the management of the topological information in the PDBQT file. The standard molecule representation contains atoms, coordinates, atom types, and bonds, but reproducing the behaviour of smina also requires information related to the ligand torsion tree.</p>\n<p>In particular, two pieces of data are needed:</p>\n<ul>\n<li>the mobility matrix, used to determine which pairs of ligand atoms can move relative to each other;</li>\n<li>the list of rotors, used to calculate the number of rotatable torsions <code class=\"formula-inline\">N_{rot}</code>.</li>\n</ul>\n<p>These data are derived from the <code>ROOT</code>/<code>BRANCH</code>/<code>ENDBRANCH</code> structure of the PDBQT file. When a ligand in PDBQT format is parsed, an intermediate representation of the torsion tree is constructed. The <code>ROOT</code> node contains the atoms of the main rigid block, while each <code>BRANCH</code> represents a rotatable bond between a <code>from</code> atom and a <code>to</code> atom, together with the subtree of atoms that depends on that bond.</p>\n<p>This representation is used during parsing to construct the mobility matrix with semantics consistent with smina and to obtain the list of ligand rotors. Only the data required by subsequent phases are kept in <code>static_molecule</code>, through the <code>pdbqt_ligand_data</code> field.</p>\n<p>This choice is forced by the existing pipeline, where ligands processed in batches are represented as <code>static_molecule</code>, while the protein is loaded separately as <code>dynamic_molecule</code>, stable data for the run. A possible refactor of the type used by the ligand queue remains open. If the pipeline supported a more specific type, the PDBQT data could be moved out of <code>static_molecule</code> and collected in a layer dedicated to Vinardo.</p>\n<p>This pragmatic solution avoids reparsing the PDBQT file inside the scoring stage. The stage can instead directly use the mobility matrix and rotor list during batch preparation.</p>\n<h2>Vinardo layer and atom typing</h2>\n<p>Once the molecule representation is available, a view of the atoms consistent with the Vinardo scoring model must be constructed. The parsed molecule contains atom types following the AutoDock scheme, while Vinardo uses a set of atom types and properties derived from the model used by Vina.</p>\n<p>For this reason, a <code>vinardo_layer</code> was introduced on top of the existing <code>molecule</code>, following the same approach already used by the other layers in the codebase. The layer adds a Vinardo-specific view, exposing the Vinardo type, atomic radius, and flags required to determine whether each atom can contribute to hydrophobic or hydrogen-bond interactions.</p>\n<p>Type conversion takes place in multiple steps. First, the AutoDock type used internally by muDock is normalised into an intermediate domain compatible with the Vinardo conversion table. An initial Vinardo type is assigned from this domain, but it may still be ambiguous. Ambiguous cases are then resolved using the topological context of the molecule.</p>\n<p>In particular, the bond graph is used to distinguish atoms that have the same initial type but different properties for scoring purposes, for example based on the presence of heteroatom neighbours or polar hydrogens.</p>\n<p>The result of the layer is then used by preprocessing to construct valid atom pairs and precompute the pair properties required by the scoring function.</p>\n<h2>Atom-pair preprocessing</h2>\n<p>The scoring function is applied to a set of atom pairs selected in advance. For this reason, a preprocessing phase was introduced that constructs the pairs to evaluate during scoring from the Vinardo layers of the protein and ligand.</p>\n<p>Protein-ligand pairs are generated by considering every combination of protein and ligand atoms, excluding pairs that contain hydrogens. The ligand-ligand case requires more work because not every internal pair in the ligand must contribute to the score. First, the ligand bond graph is constructed and a Breadth First Search limited to depth three is performed for each atom. The result is saved in a <code>within_three_bonds</code> matrix, which indicates whether two atoms are reachable within three covalent bonds.</p>\n<p>A ligand-ligand pair is therefore retained only when both conditions hold: the two atoms are not reachable within three covalent bonds and they are marked as relatively mobile in the mobility matrix constructed from the PDBQT torsion tree.</p>\n<p>For each selected pair, the indices of the two atoms, the sum of the atomic radii, and two Boolean flags are saved. One flag indicates whether the pair can contribute to the hydrophobic term and the other whether it can contribute to the hydrogen-bond term. These data are independent from the specific pose and can therefore be calculated once before kernel execution.</p>\n<p>In the current implementation, these data are kept in the private buffers of the stage for the current batch. One possible future optimisation would expose them in the pipeline scratchpad if they needed to be reused by multiple stages or preserved beyond a single stage preparation. During genetic exploration, the generated poses change, but ligand topology, atom types, and the set of valid pairs do not. The kernel therefore only needs to update the transformed coordinates and geometric distances, reusing the already computed pair indices, radius sums, and interaction flags.</p>\n<p>In general, this preprocessing phase reduces the work performed during scoring. The kernel does not need to query the chemical structure of the molecule and can instead iterate over prepared arrays, calculating only the geometric distances and the energy terms that depend on them for each pose.</p>\n<h2>Vinardo scoring stage</h2>\n<p>The central point of the integration is the scoring stage, where the chemical representation built in the previous phases is transformed into the data actually used to calculate the score.</p>\n<p>Like other existing stages, the <code>vinardo_score</code> stage implements the three-phase contract. In <code>prepare()</code>, it reads the data available in the scratchpads and constructs the buffers required by the kernel. In <code>operator()</code>, the computation is executed and the <code>SCORES</code> buffer is populated. In <code>teardown()</code>, the result is retrieved and written into the ligand properties.</p>\n<p>The stage contains different categories of data with different roles and lifetimes in the computation. They are organised into three levels.</p>\n<p>The first level is the device scratchpad, maintained in the stage code as <code>device_scratch</code> and used for data that remains stable across batches. For Vinardo, it contains the protein coordinates in the <code>PROT_X</code>, <code>PROT_Y</code>, and <code>PROT_Z</code> buffers because the protein is shared by every ligand processed during the run.</p>\n<p>The second level is the pipeline scratchpad, accessible in the stage through the <code>scratch</code> member and shared by stages operating on the same batch. This scratchpad contains flat buffers used as communication points between stages, produced from the C++ objects constructed previously. For example, in the genetic pipeline, the genetic stage produces new poses by modifying the ligand coordinates and writes them into the <code>X_SCRATCH</code>, <code>Y_SCRATCH</code>, and <code>Z_SCRATCH</code> buffers. The scoring stage then reads the already transformed coordinates from those same buffers and writes the results into <code>SCORES</code>.</p>\n<p>The third level consists of the private buffers of the <code>vinardo_score</code> stage. These contain data required only by the Vinardo kernel, such as offsets, indices of protein-ligand and ligand-ligand pairs, sums of atomic radii, and interaction flags. These buffers represent a flattened version of the data described through more convenient C++ objects in the previous phases. The stage converts them into contiguous arrays that are more suitable for sequential kernel access.</p>\n<p>The <code>prepare()</code> phase is where this data organisation is concretely constructed for the current batch. It receives a batch of <code>static_molecule</code> objects and first loads data shared with the other stages into <code>scratch</code>, such as atom counts and the working coordinate buffers of the ligands.</p>\n<p>Next, a <code>vinardo_ligand</code> is constructed for every ligand in the batch. This object uses the <code>vinardo_layer</code> and the PDBQT data already associated with the molecule to calculate ligand-ligand pairs and the number of rotatable torsions <code class=\"formula-inline\">N_{rot}</code>. Protein-ligand pairs are instead generated inside the stage.</p>\n<p>The <code>prepare()</code> phase ends by moving the private stage buffers to the device and constructing the <code>vinardo_score_kernel</code>, which receives pointers to those buffers. From this point onward, the computation uses only the linearised data prepared for the current batch.</p>\n<p>The <code>operator()</code> phase simply invokes the kernel and delegates the actual score computation to it.</p>\n<p>During <code>teardown()</code>, the stage copies the <code>SCORES</code> buffer from the device to the host and assigns the score to the <code>SCORE</code> property of the ligand. In this way, the result reenters the normal output mechanism of the pipeline.</p>\n<h2>Vinardo scoring kernel</h2>\n<p>The kernel is the component that consumes the buffers produced by <code>prepare()</code> and calculates the score of the poses in the batch when the stage invokes <code>operator()</code>. The general interface is defined by <code>vinardo_score_kernel&lt;queue_type&gt;</code>. The object maintains pointers to ligand coordinates, protein coordinates, pair arrays, interaction flags, the number of rotatable torsions, and the <code>SCORES</code> buffer.</p>\n<p>In this project, the kernel was implemented only for the CPU backend through <code>queue_cpp</code>. In this version, <code>operator()</code> calls <code>calc_vinardo_energy</code>, passing the pointers to the buffers prepared by the stage.</p>\n<p>The <code>calc_vinardo_energy()</code> function iterates over the ligands in the batch and the poses associated with each ligand. For each pose, it separately calculates the protein-ligand contribution and the ligand-ligand contribution. For each pair, it reads the atom indices, retrieves the coordinates, and calculates the Euclidean distance between the two atoms. If the distance is greater than or equal to 8 Angstrom, the pair is discarded. Otherwise, the surface distance is calculated by subtracting the precomputed sum of atomic radii from the Euclidean distance. The surface distance and the precomputed hydrophobic and hydrogen-bond interaction flags are then passed to <code>compute_vinardo_pair_energy()</code>.</p>\n<p>The function that calculates the energy contribution of a single pair was kept separate from the kernel. This choice made it possible to validate the Vinardo formula independently of the execution infrastructure. The separation also makes it possible to expose debugging APIs, useful for observing the individual energy terms and verifying them against the reference.</p>\n<p>The kernel keeps the intermolecular and intramolecular contributions separate. This is required to calculate affinity correctly when multiple poses are present for the same ligand. First, the best pose is identified according to the raw score. Each pose is then corrected using the intramolecular contribution of the reference pose and normalised through <code class=\"formula-inline\">N_{rot}</code>. The final value is written into the <code>SCORES</code> buffer.</p>\n<h2>Scoring and genetic pipelines</h2>\n<p>The pipeline integration is completed in <code>pipeline.hpp</code>, where the <code>vinardo_score_pipeline</code> and <code>genetic_vinardo_pipeline</code> aliases were added. The first instantiates the <code>vinardo_score</code> stage in the scoring-only pipeline, while the second uses it as the scoring stage inside the genetic pipeline. In this way, Vinardo follows the same pattern already used by AutoDock without requiring a separate flow.</p>\n<p>The search algorithm and scoring function can be selected at runtime through the CLI options <code>--search</code>, with the values <code>none</code> and <code>genetic</code>, and <code>--score</code>, with the values <code>adt</code> and <code>vinardo</code>. This makes it possible to freely combine the four available configurations.</p>\n<h1>Validation and Results</h1>\n<p>This section describes the process used to validate the Vinardo implementation and the results obtained from comparison with the reference.</p>\n<h2>Validation strategy</h2>\n<p>Validation was organised incrementally, using smina as the reference implementation for the expected Vinardo behaviour. The final affinity value was therefore not the only value validated. The work was instead divided into the main components of the pipeline: atom typing, pair preprocessing, reconstruction of ligand topology information, and affinity calculation.</p>\n<p>To avoid introducing debugging code into the final PR, part of the work was carried out on parallel validation branches. These branches were derived from the corresponding development branches and preserved the same implementation logic, but added dump and instrumentation tools to export data that could be compared with smina. This made it possible to associate atoms, atom pairs, and energy contributions through consistent indices across the two codebases, identifying bugs and divergences at the level of individual components. Development therefore moved to the next component only after the newly introduced one had been completely aligned with the reference.</p>\n<h2>Validation dataset</h2>\n<p>During development, a set of 15 complexes from PDBbind v2024 was used to provide broader validation. The considered complexes were identified by the following IDs: 1BNM, 1FKF, 1NLO, 1NLP, 1ABT, 1B2I, 1AO8, 1CWB, 1CWC, 4SGA, 1STD, 220L, 1ILQ, 1B56, and 1EVH.</p>\n<h2>Incremental validation of the components</h2>\n<p>The following table summarises the main validation points used during development. For each component, the output produced by muDock was compared with the output obtained from the reference.</p>\n<table><thead><tr><th>Component</th><th>Comparison</th><th>Result</th><th>Reference</th></tr></thead><tbody><tr><td>Atom typing</td><td>Vinardo types assigned to protein and ligand atoms</td><td>Aligned with smina after correction</td><td><a href=\"https://github.com/T-vaccari/muDock/pull/5\" rel=\"noopener noreferrer\">PR #5</a> / <a href=\"https://github.com/T-vaccari/muDock/pull/8\" rel=\"noopener noreferrer\">PR #8</a></td></tr><tr><td>Protein-ligand preprocessing</td><td>Protein-ligand atom pairs selected for scoring</td><td>Aligned after excluding hydrogens</td><td><a href=\"https://github.com/T-vaccari/muDock/pull/6\" rel=\"noopener noreferrer\">PR #6</a> / <a href=\"https://github.com/T-vaccari/muDock/pull/7\" rel=\"noopener noreferrer\">PR #7</a></td></tr><tr><td>Ligand-ligand preprocessing</td><td>Internal ligand pairs selected through topological constraints and relative mobility</td><td>Aligned using the PDBQT torsion tree</td><td><a href=\"https://github.com/T-vaccari/muDock/pull/6\" rel=\"noopener noreferrer\">PR #6</a> / <a href=\"https://github.com/T-vaccari/muDock/pull/10\" rel=\"noopener noreferrer\">PR #10</a></td></tr><tr><td>Number of rotatable torsions</td><td><code>N_{rot}</code> used to normalise affinity</td><td>Reconstructed as in the reference</td><td><a href=\"https://github.com/T-vaccari/muDock/pull/10\" rel=\"noopener noreferrer\">PR #10</a></td></tr><tr><td>Scoring</td><td>Final affinity value on benchmark cases</td><td>Close to the reference, with an explainable residual delta</td><td><a href=\"https://github.com/T-vaccari/muDock/pull/12\" rel=\"noopener noreferrer\">PR #12</a> / <a href=\"https://github.com/T-vaccari/muDock/pull/13\" rel=\"noopener noreferrer\">PR #13</a></td></tr></tbody></table>\n<h2>Divergences found during validation</h2>\n<p>The incremental comparison showed that alignment did not depend only on the formula in the paper, but also on several implementation choices. The most relevant cases are reported below because they led to corrections or implementation decisions in the final code.</p>\n<h3>Atom typing</h3>\n<p>The per-atom comparison with smina highlighted errors in the table of data associated with the Vinardo types, in particular in the flags and values later used by preprocessing and the scoring function. The table was corrected and the comparison repeated until the same typing as the reference was obtained.</p>\n<h3>Hydrogen management</h3>\n<p>In the first preprocessing comparison, muDock generated more atom pairs than smina. The difference came from the reference excluding pairs that contain at least one hydrogen atom, while the initial implementation retained them. Preprocessing was therefore modified to apply the same filter to both protein-ligand and ligand-ligand pairs.</p>\n<h3>Ligand-ligand pairs</h3>\n<p>After excluding hydrogens, a divergence remained in the internal ligand pairs. The problem was not related to the topological distance between atoms, but to the definition of relative mobility.</p>\n<p>As explained in <a href=\"https://github.com/T-vaccari/muDock/pull/6#issuecomment-4333345740\" rel=\"noopener noreferrer\">this comment</a>, the initial implementation used a component already present in muDock: fragments. For each rotatable bond, this component temporarily removes the corresponding connection from the graph, assuming that the rotatable bond represents a valid cut of the graph. A BFS is then performed over the components reachable from the two endpoints of the bond, identifying the atoms on both sides of the cut and constructing the mobile-fragment mask.</p>\n<p>This concept was not semantically identical to the one used by the reference, which derives relative mobility from the PDBQT torsion tree. The concrete divergence mainly concerned pairs involving the atoms at the endpoints of the rotor. In the semantics reconstructed from the reference, the <code>from</code>/<code>to</code> pair and the pairs between each of these two atoms and the atoms in the sub-branch are marked as not relatively mobile. The fragment-based logic instead included these pairs among the mobile ones. As a result, preprocessing selected more ligand-ligand pairs than the reference.</p>\n<p>To align the behaviour, a mobility matrix with semantics consistent with smina was introduced.</p>\n<h3>Number of rotatable torsions</h3>\n<p>Another divergence concerned the value of <code class=\"formula-inline\">N_{rot}</code> used to normalise affinity. Here too, directly using either the number of rotors already available in the molecule or the value present in the PDBQT file was not sufficient because the reference applies its own semantics to the rotors that are actually counted.</p>\n<p>To align the behaviour, the torsion-tree representation introduced for the mobility matrix was reused. Starting from the rotors described in the PDBQT file, those where at least one endpoint is a hydrogen or does not have at least two bonds with non-hydrogen atoms are excluded. The resulting value is then used in the affinity calculation, making the normalisation consistent with smina.</p>\n<h3>Curl of positive energies</h3>\n<p>After aligning the previous components, a residual delta from smina remained. This delta was traced back to <code>curl</code>, a transformation applied by the reference to positive energy contributions before the final normalisation of affinity.</p>\n<p>However, <code>curl</code> is applied to intermediate computation results at a level that is not present in the implementation developed in this project. It is also an implementation choice of the reference rather than a term described in the theoretical formulation of Vinardo. For this reason, it was not included in the final implementation.</p>\n<p>A test was still performed on an instrumented branch, restructuring the code to expose the same intermediate results. When <code>curl</code> was also applied, the final affinity value aligned with the reference up to machine precision.</p>\n<h2>Final validation</h2>\n<p>Final validation on the dataset compared the affinity calculated by muDock using Vinardo with the affinity produced by smina on the same inputs. The results are shown below.</p>\n<table><thead><tr><th>PDB ID</th><th>muDock affinity</th><th>smina affinity</th><th>Absolute delta</th></tr></thead><tbody><tr><td>1BNM</td><td>-6.669172</td><td>-6.669182</td><td><code>1.001600 \\cdot 10^{-5}</code></td></tr><tr><td>1FKF</td><td>-11.700834</td><td>-11.700848</td><td><code>1.381000 \\cdot 10^{-5}</code></td></tr><tr><td>1NLO</td><td>1.007401</td><td>0.992802</td><td><code>1.459884 \\cdot 10^{-2}</code></td></tr><tr><td>1NLP</td><td>-3.374581</td><td>-3.378935</td><td><code>4.354485 \\cdot 10^{-3}</code></td></tr><tr><td>1ABT</td><td>-8.456276</td><td>-8.456291</td><td><code>1.554700 \\cdot 10^{-5}</code></td></tr><tr><td>1B2I</td><td>-2.721796</td><td>-2.723378</td><td><code>1.582090 \\cdot 10^{-3}</code></td></tr><tr><td>1AO8</td><td>-6.664783</td><td>-6.664826</td><td><code>4.205000 \\cdot 10^{-5}</code></td></tr><tr><td>1CWB</td><td>-7.065887</td><td>-7.065935</td><td><code>4.775400 \\cdot 10^{-5}</code></td></tr><tr><td>1CWC</td><td>-7.466530</td><td>-7.466600</td><td><code>6.955900 \\cdot 10^{-5}</code></td></tr><tr><td>4SGA</td><td>-8.104091</td><td>-8.104555</td><td><code>4.640370 \\cdot 10^{-4}</code></td></tr><tr><td>1STD</td><td>-9.236320</td><td>-9.236327</td><td><code>6.967000 \\cdot 10^{-6}</code></td></tr><tr><td>220L</td><td>-4.433152</td><td>-4.433151</td><td><code>8.110000 \\cdot 10^{-7}</code></td></tr><tr><td>1ILQ</td><td>-5.019547</td><td>-5.020099</td><td><code>5.520600 \\cdot 10^{-4}</code></td></tr><tr><td>1B56</td><td>-5.262350</td><td>-5.262351</td><td><code>1.259000 \\cdot 10^{-6}</code></td></tr><tr><td>1EVH</td><td>-7.126778</td><td>-7.126916</td><td><code>1.374770 \\cdot 10^{-4}</code></td></tr></tbody></table>\n<p>Across the 15 cases, the mean absolute delta was <code class=\"formula-inline\">1.459784 \\cdot 10^{-3}</code>, with a maximum delta of <code class=\"formula-inline\">1.459884 \\cdot 10^{-2}</code>. These results show that the implementation is aligned with the reference, with residual differences explained by the absence of <code>curl</code> in this implementation.</p>\n<h2>CASF forward-screening benchmark</h2>\n<p>In addition to numerical validation against smina, a functional forward-screening experiment was performed on the CASF-2016 dataset. The Vinardo test was conducted on the 57 targets of the CASF-2016 benchmark, using the scores produced by the CPU scoring-only pipeline and evaluating them with the official forward-screening script provided with CASF.</p>\n<p>In the forward-screening test, 285 ligands and 100 poses per ligand are considered for each target. Each ligand is assigned the best score among its poses and the ligands are ranked according to that score. The top <code class=\"formula-inline\">1\\%</code>, <code class=\"formula-inline\">5\\%</code>, and <code class=\"formula-inline\">10\\%</code> are then evaluated. With 285 ligands and CASF rounding, these correspond to 3, 14, and 29 ligands respectively.</p>\n<p>Let <code class=\"formula-inline\">T</code> be the set of targets, <code class=\"formula-inline\">R_t^\\alpha</code> the ligands selected in the top <code class=\"formula-inline\">\\alpha</code> for target <code class=\"formula-inline\">t</code>, <code class=\"formula-inline\">B_t</code> the set of known binders for that target, and <code class=\"formula-inline\">L1_t</code> the binder with the best experimental affinity. Two metrics were reported:</p>\n<div class=\"formula\"><code>SR_\\alpha = \\frac{1}{|T|}\\sum_{t \\in T}\\mathbf{1}[L1_t \\in R_t^\\alpha]\\cdot 100</code></div>\n<p>Success Rate measures the percentage of targets for which the best experimental binder appears in the top <code class=\"formula-inline\">\\alpha</code>. Enrichment Factor measures how much the ranking enriches true binders compared with random selection:</p>\n<div class=\"formula\"><code>EF_\\alpha = \\frac{1}{|T|}\\sum_{t \\in T}\n\\frac{|R_t^\\alpha \\cap B_t|}{|B_t| \\cdot \\alpha}</code></div>\n<p>An <code class=\"formula-inline\">EF</code> value close to 1 indicates approximately random behaviour. The denominator represents the expected number of known binders obtained through random selection.</p>\n<table><thead><tr><th>Method</th><th>Ranking</th><th>Top 1% (3)</th><th>Top 5% (14)</th><th>Top 10% (29)</th></tr></thead><tbody><tr><td>Vinardo</td><td>lower is better</td><td>33.3% / 9.34</td><td>49.1% / 4.18</td><td>63.2% / 3.19</td></tr></tbody></table>\n<p>Each cell reports Success Rate / Enrichment Factor.</p>\n<p>The results show very good enrichment for Vinardo across the complete benchmark of 57 targets, with the best experimental binder recovered in the top <code class=\"formula-inline\">1\\%</code>, <code class=\"formula-inline\">5\\%</code>, and <code class=\"formula-inline\">10\\%</code> for 19, 28, and 36 targets respectively.</p>\n<h2>Automated CTests</h2>\n<p>The extended validation over the 15 PDBbind complexes was used during development, but it was not included directly in the CTests of the final PR. The automatic tests instead use the complexes already present in muDock, avoiding the introduction of new datasets into the codebase.</p>\n<p>For each complex used in the CTests, a reference affinity value produced by smina was saved. The automatic test runs the muDock Vinardo pipeline on the same PDBQT files and verifies that the calculated affinity remains within an absolute tolerance of <code class=\"formula-inline\">10^{-3}</code> from the reference.</p>\n<p>In this way, the CTests automatically verify that subsequent changes do not alter the behaviour of the Vinardo pipeline integrated into muDock.</p>\n<h1>Conclusions</h1>\n<p>The work introduced the Vinardo scoring function into muDock and integrated it into the existing pipeline. The integration required constructing a Vinardo view of the molecules, converting atom types, preprocessing atom pairs, reconstructing ligand topology information from PDBQT, and implementing the scoring stage with its CPU kernel.</p>\n<p>Validation was performed by progressively comparing the components with smina, used as the reference implementation. This made it possible to identify and correct divergences such as hydrogen management, the semantics of relative mobility inside the ligand, and the calculation of the number of rotatable torsions. The final comparison over 15 PDBbind complexes shows good alignment with the reference. The residual differences can be traced back to the <code>curl</code> applied by smina to intermediate contributions, which was not included in the final implementation.</p>\n<h2>Future developments</h2>\n<p>Some future developments remain. During PR review, a problem emerged in estimating the memory required by protein-ligand buffers. An initial version restricted Vinardo to a batch size of one because the static estimation API receives only the number of ligand atoms. This constraint was removed by introducing a conservative estimate based on an upper bound for the number of considered protein atoms. The estimate could still be refined, especially for accelerated backends and more precise preallocation.</p>\n<p>A second point concerns the auxiliary data extracted from PDBQT. It is currently constructed during static molecule parsing even when it is not required, although it is used only by Vinardo. With runtime selection of the scoring function, this data could be constructed only in the path that uses it.</p>\n<p>Another future development is extending the Vinardo kernel to backends other than the CPU version implemented in this project. Finally, parts of preprocessing could be optimised by storing pose-independent data in the scratchpad, such as valid atom pairs and their associated data, avoiding reconstruction when ligand topology does not change.</p>\n<h2>Conclusion</h2>\n<p>Overall, the project added a functional Vinardo scoring path to muDock, validated it against an external reference, and integrated it into the stage-based model of the codebase. It provides complete runtime support through the CLI for selecting the search algorithm and scoring function, while leaving a solid foundation for a future port to accelerated backends.</p>\n<h1>References</h1>\n<ul>\n<li>Rodrigo Quiroga and Miguel A. Villarreal, <a href=\"https://doi.org/10.1371/journal.pone.0155183\" rel=\"noopener noreferrer\">“Vinardo: A Scoring Function Based on Autodock Vina Improves Scoring, Docking, and Virtual Screening”</a>, PLOS ONE, 2016.</li>\n<li>Oleg Trott and Arthur J. Olson, <a href=\"https://doi.org/10.1002/jcc.21334\" rel=\"noopener noreferrer\">“AutoDock Vina: improving the speed and accuracy of docking with a new scoring function, efficient optimization, and multithreading”</a>, Journal of Computational Chemistry, 2010.</li>\n<li>David R. Koes, Matthew P. Baumgartner, and Carlos J. Camacho, <a href=\"https://doi.org/10.1021/ci300604z\" rel=\"noopener noreferrer\">“Lessons Learned in Empirical Scoring with smina from the CSAR 2011 Benchmarking Exercise”</a>, Journal of Chemical Information and Modeling, 2013.</li>\n<li>Garrett M. Morris et al., <a href=\"https://pmc.ncbi.nlm.nih.gov/articles/PMC2760638/\" rel=\"noopener noreferrer\">“AutoDock4 and AutoDockTools4: Automated Docking with Selective Receptor Flexibility”</a>, Journal of Computational Chemistry, 2009.</li>\n<li><a href=\"https://github.com/elvispolimi/muDock\" rel=\"noopener noreferrer\">muDock</a>.</li>\n<li><a href=\"https://arxiv.org/abs/2509.12232\" rel=\"noopener noreferrer\">“Towards High-Performance and Portable Molecular Docking on CPUs through Vectorization”</a>, 2025.</li>\n</ul>","date_published":"2026-07-07T00:00:00.000Z","tags":["C++","HPC","Molecular Docking","Drug Discovery"]},{"id":"https://www.tommasovaccari.com/blog/deep-dive-on-a-custom-gpt-architecture","url":"https://www.tommasovaccari.com/blog/deep-dive-on-a-custom-gpt-architecture","title":"Deep Dive into a Custom GPT Architecture","summary":"A from-scratch GPT implementation, its computational cost, and experiments on arithmetic and character-level language modeling.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<p>Now that I have covered the fundamentals of neural networks and have a grasp of how a transformer works, I decided to build a small GPT from scratch, train it on different datasets, and see what happens.</p>\n<p>The first step, as usual, was to implement it without relying on libraries that already provide the complete architecture. Rebuilding it from scratch helps me remember that there is nothing magical about it: it is math and scale working together to produce something that can appear surprisingly complex.</p>\n<p>I wanted to have a solid base that I could fork whenever I wanted to play around with a model, so I created a repository that I can use as a starting point for different experiments. The details need to change depending on the experiment, but the core architecture remains the same.</p>\n<p>You can play with this model even on a laptop, but if you want to experiment with a bit more scale, you will need a GPU. For these experiments, I am using an old PC that I built in 2020. It is not particularly powerful, but it is a good starting point. I turned it into a server running Ubuntu LTS and connected it through Tailscale, so I can access it over SSH. It has 32 GB of RAM, a Ryzen 5 3600, and, most importantly, an RX 5600 XT. To use the GPU as a backend, I had to compile a custom PyTorch build and configure it so that the code can access the GPU through the CUDA device interface exposed by PyTorch.</p>\n<p>Before looking at the architecture, I want to derive the computational cost of training and inference for a model like this one.</p>\n<h2>Deriving the computational cost of training and inference from scratch</h2>\n<p>Computational cost is usually measured in FLOPs (floating-point operations). In this approximation, one multiplication and one addition each count as one FLOP. The goal is to derive a rough mental model of the computational cost of training and inference for an LLM.</p>\n<p>We can start from the basics. The fundamental building block is matrix multiplication. Suppose we want to multiply the following matrices:</p>\n<div class=\"formula\"><code>C = A @ B</code></div>\n<p>where <code class=\"formula-inline\">A</code> has shape <code class=\"formula-inline\">M \\times K</code>, <code class=\"formula-inline\">B</code> has shape <code class=\"formula-inline\">K \\times N</code>, and <code class=\"formula-inline\">C</code> has shape <code class=\"formula-inline\">M \\times N</code>. Each element of <code class=\"formula-inline\">C</code> is obtained from the dot product between one row of <code class=\"formula-inline\">A</code> and one column of <code class=\"formula-inline\">B</code>, so:</p>\n<div class=\"formula\"><code>FLOPs = 2 * \\ K * \\ N * M</code></div>\n<p>For each dot product, we perform <code class=\"formula-inline\">K</code> multiplications and <code class=\"formula-inline\">K-1</code> additions, which we approximate as <code class=\"formula-inline\">2K</code> operations. We repeat this for every row of <code class=\"formula-inline\">A</code> and every column of <code class=\"formula-inline\">B</code>.</p>\n<p>Now we can extend that reasoning to a classic linear layer:</p>\n<div class=\"formula\"><code>y = x@w</code></div>\n<p>Nothing that we have not seen yet, it's still a matrix multiplication.\nSuppose that <code class=\"formula-inline\">x</code> has shape <code class=\"formula-inline\">(B,T,C)</code>, where <code class=\"formula-inline\">B</code> is the batch size, <code class=\"formula-inline\">T</code> is the sequence length, and <code class=\"formula-inline\">C</code> is the embedding dimension. The weight matrix <code class=\"formula-inline\">W</code> has shape <code class=\"formula-inline\">(C,D)</code>.</p>\n<p>We can treat the first two dimensions of <code class=\"formula-inline\">x</code> as <code class=\"formula-inline\">B \\cdot T</code> rows, each multiplied by <code class=\"formula-inline\">W</code>. The cost is therefore:</p>\n<div class=\"formula\"><code>flops_{linear} = 2 \\cdot B \\cdot T \\cdot C \\cdot D</code></div>\n<p>When <code class=\"formula-inline\">C=D</code>, this becomes:</p>\n<div class=\"formula\"><code>flops_{linear} = 2 \\cdot B \\cdot T \\cdot C^2</code></div>\n<p>Now we can try to estimate the cost of an attention block.\nIn an attention block we have the following matrices:</p>\n<ul>\n<li><code class=\"formula-inline\">x</code>: <code class=\"formula-inline\">(B, T, C)</code> input</li>\n<li><code class=\"formula-inline\">W_q</code>: <code class=\"formula-inline\">(C, head\\_size)</code></li>\n<li><code class=\"formula-inline\">W_k</code>: <code class=\"formula-inline\">(C, head\\_size)</code></li>\n<li><code class=\"formula-inline\">W_v</code>: <code class=\"formula-inline\">(C, head\\_size)</code></li>\n</ul>\n<p>Then we have to perform the following operation, for a single head:</p>\n<ol>\n<li><code>q = x @ W_q</code> → <code>(B, T, head_size)</code></li>\n<li><code>k = x @ W_k</code> → <code>(B, T, head_size)</code></li>\n<li><code>v = x @ W_v</code> → <code>(B, T, head_size)</code></li>\n<li><code>scores = q @ k.transpose(-2, -1)</code> → <code>(B, T, T)</code></li>\n<li><code>attention = softmax(scores)</code> → <code>(B, T, T)</code></li>\n<li><code>y = attention @ v</code> → <code>(B, T, head_size)</code></li>\n</ol>\n<p>Now we can compute the FLOPs required by each step for a single head:</p>\n<ol>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot C \\cdot T \\cdot head\\_size</code>\n</li>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot C \\cdot T \\cdot head\\_size</code>\n</li>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot C \\cdot T \\cdot head\\_size</code>\n</li>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot head\\_size \\cdot T^2</code>\n</li>\n<li><code class=\"formula-inline\">flops \\approx 4 \\cdot B \\cdot T^2</code> (derived below)</li>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot T^2 \\cdot head\\_size</code>\n</li>\n</ol>\n<p>Since every attention head performs the same computation independently, and there are <code class=\"formula-inline\">h</code> heads, the total cost is <code class=\"formula-inline\">h</code> times the cost derived above. Using <code class=\"formula-inline\">h \\cdot head\\_size = C</code>:</p>\n<div class=\"formula\"><code>h \\cdot \\big( \\underbrace{6 \\cdot B \\cdot T \\cdot C \\cdot head\\_size}_{\\text{qkv}} + \\underbrace{4 \\cdot B \\cdot T^2 \\cdot head\\_size}_{\\text{scores + weighted sum}} \\big) + \\underbrace{4 \\cdot B \\cdot T^2 \\cdot h}_{\\text{softmax}}\n= 6BTC^2 + 4BT^2C + 4BT^2h</code></div>\n<p>After the heads are concatenated back into a <code class=\"formula-inline\">(B,T,C)</code> tensor, there's one more matmul — the output projection — which is <em>not</em> per-head, so it isn't scaled by <code class=\"formula-inline\">h</code>:</p>\n<div class=\"formula\"><code>flops_{out\\_proj} = 2 \\cdot B \\cdot T \\cdot C^2</code></div>\n<p>Adding it in:</p>\n<div class=\"formula\"><code>\\boxed{flops_{attention} = 8 \\cdot B \\cdot T \\cdot C^2 \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot C \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot h}</code></div>\n<p><strong>On the softmax cost</strong>: Softmax on a row of length <code class=\"formula-inline\">T</code> requires: a max-reduction for numerical stability (<code class=\"formula-inline\">T-1</code> comparisons), a subtract-and-exponentiate pass (<code class=\"formula-inline\">2T</code> flops — one subtraction and one exp per element, counting exp as 1 flop by convention), a sum-reduction (<code class=\"formula-inline\">T-1</code> additions), and a final divide (<code class=\"formula-inline\">T</code> divisions). That's <code class=\"formula-inline\">\\approx 5T</code> flops per row, though <code class=\"formula-inline\">4T</code> is the more common rounding in the literature and the two are interchangeable at this level of approximation. There are <code class=\"formula-inline\">B \\cdot T</code> rows per head (one per query position, per batch element), so one head's softmax costs <code class=\"formula-inline\">\\approx 4 \\cdot B \\cdot T^2</code> — independent of <code class=\"formula-inline\">head\\_size</code>. This is why it has to be added on top of the per-head total above rather than folded into it via the <code class=\"formula-inline\">h \\cdot head\\_size = C</code> substitution: it's the one term whose cost doesn't factor through that product, since it scales with the <em>number</em> of heads (independent softmax calls), not with how wide each head is.</p>\n<p>Now we can derive the cost of the other fundamental block: the feedforward network. It usually contains two linear layers with a non-linearity between them. The first linear layer expands the input dimension by a factor of four, and the second projects it back to the original dimension:</p>\n<ol>\n<li>h = x @ W1 (B, T, C) @ (C, 4C) -&gt; (B, T, 4C)</li>\n<li>h = activation(h) (B, T, 4C) -&gt; (B, T, 4C)</li>\n<li>y = h @ W2 (B, T, 4C) @ (4C, C) -&gt; (B, T, C)</li>\n</ol>\n<p>We already have the formula for a linear layer, so we can reuse it directly for step 1 and step 3.</p>\n<ol>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot T \\cdot C \\cdot 4C = 8 \\cdot B \\cdot T \\cdot C^2</code>\n</li>\n<li>activation is elementwise, cost linear in the number of elements (<code class=\"formula-inline\">B \\cdot T \\cdot 4C</code>), negligible compared to the matmuls, so we drop it</li>\n<li>\n<code class=\"formula-inline\">flops = 2 \\cdot B \\cdot T \\cdot 4C \\cdot C = 8 \\cdot B \\cdot T \\cdot C^2</code>\n</li>\n</ol>\n<p>Summing:</p>\n<div class=\"formula\"><code>\\boxed{flops_{FFN} = 16 \\cdot B \\cdot T \\cdot C^2}</code></div>\n<p>Notice this is exactly twice the <code class=\"formula-inline\">8 \\cdot B \\cdot T \\cdot C^2</code> term from attention (the qkv + output projection part) — the FFN is the more expensive of the two sub-blocks per layer, by a factor of 2, for the standard 4x expansion ratio.</p>\n<p>We can now combine the two components to obtain the computational cost of one transformer layer, which contains an attention block and a feedforward network. A complete transformer stacks multiple layers of this type.</p>\n<div class=\"formula\"><code>\\boxed{flops_{layer} = flops_{attention} + flops_{ffn} = 8 \\cdot B \\cdot T \\cdot C^2 \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot C \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot h \\;+\\;16 \\cdot B \\cdot T \\cdot C^2 }</code></div>\n<p>So at the end we obtain</p>\n<div class=\"formula\"><code>\\boxed{flops_{layer} = 24 \\cdot B \\cdot T \\cdot C^2 \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot C \\;+\\; 4 \\cdot B \\cdot T^2 \\cdot h }</code></div>\n<p>If the model contains <code class=\"formula-inline\">l</code> layers, their contribution to one forward pass is <code class=\"formula-inline\">l</code> times <code class=\"formula-inline\">flops_{layer}</code>. The complete model also contains the embedding layer and the final projection over the vocabulary.</p>\n<p>The embedding layer is basically a lookup, so we can consider its arithmetic cost equal to zero in this approximation. At the end of the model we also have the language model head, which projects the representation of every token from <code class=\"formula-inline\">C</code> to <code>vocab_size</code>. This is another linear layer, so its cost is:</p>\n<div class=\"formula\"><code>flops_{lm\\_head} = 2 \\cdot B \\cdot T \\cdot C \\cdot vocab\\_size</code></div>\n<p>Even when the language model head shares its weights with the token embedding, we still have to perform this matrix multiplication. Weight tying reduces the number of parameters, not the computational cost of the projection.</p>\n<p>Putting together the <code class=\"formula-inline\">l</code> transformer layers and the final projection, we obtain the cost of one complete forward pass:</p>\n<div class=\"formula\"><code>\\boxed{flops_{forward} = l \\cdot \\left(24 \\cdot B \\cdot T \\cdot C^2 + 4 \\cdot B \\cdot T^2 \\cdot C + 4 \\cdot B \\cdot T^2 \\cdot h\\right) + 2 \\cdot B \\cdot T \\cdot C \\cdot vocab\\_size}</code></div>\n<p>Often it is more useful to reason about the cost per token. In one forward pass we process <code class=\"formula-inline\">B \\cdot T</code> tokens, so we can divide the previous formula by this value:</p>\n<div class=\"formula\"><code>\\boxed{flops_{forward\\_per\\_token} = 24 \\cdot l \\cdot C^2 + 4 \\cdot l \\cdot T \\cdot C + 4 \\cdot l \\cdot T \\cdot h + 2 \\cdot C \\cdot vocab\\_size}</code></div>\n<p>This is the cost of a complete forward pass over a sequence of length <code class=\"formula-inline\">T</code>. In this implementation, autoregressive generation runs another forward pass for every generated token. Implementations with a KV cache can reuse previous keys and values, changing the inference cost.</p>\n<p>These formulas make the two main scaling behaviours more visible. The projections inside the transformer grow quadratically with the embedding dimension <code class=\"formula-inline\">C</code>. In the complete forward pass, attention grows quadratically with the context length <code class=\"formula-inline\">T</code>; after dividing by the number of tokens, the corresponding per-token cost grows linearly with <code class=\"formula-inline\">T</code>.</p>\n<p>For training we also have to perform the backward pass. A useful rough approximation is that the backward pass costs around two times the forward pass:</p>\n<div class=\"formula\"><code>flops_{backward} \\approx 2 \\cdot flops_{forward}</code></div>\n<p>The complete training cost is then approximately three times the forward cost. Per token we obtain:</p>\n<div class=\"formula\"><code>\\boxed{flops_{training\\_per\\_token} \\approx 72 \\cdot l \\cdot C^2 + 12 \\cdot l \\cdot T \\cdot C + 12 \\cdot l \\cdot T \\cdot h + 6 \\cdot C \\cdot vocab\\_size}</code></div>\n<h2>Custom GPT Architecture</h2>\n<p>Clearly I have not developed from scratch this model architecture, is derived from the original attention paper, plus some other sources, like karpathy's videos and also deep learning books. It's pretty easy, nothing too complicated, I just want to graps the fundamentals, so it's deliberately written in clanky python and pytorch, nothing that must be taken for serious.</p>\n<p>You may already be familiar with the architecture, so you can skip this section. To better retain what I am learning, I like to sketch the systems I am building. In this case, I used Excalidraw, which I found very helpful:</p>\n<p><img src=\"https://www.tommasovaccari.com/static/architecture-64a5bdb9.webp\" alt=\"architecture\" /></p>\n<p>As we have already seen, the classic transformer block contains a self-attention layer followed by a feedforward network. The main difference is the placement of layer normalization: the original paper uses post-norm, while this implementation uses pre-norm. Pre-norm generally makes optimization more stable, especially as the number of layers increases.</p>\n<p>The implementation is available on <a href=\"https://github.com/T-vaccari/CustomGPT\" rel=\"noopener noreferrer\">GitHub</a>, together with the code required for training and inference. Instead of loading the entire training set into RAM, I used NumPy memory mapping to create a disk-backed array and load only the required portions. This prevented crashes and out-of-memory errors during training.</p>\n<p>Now we can look at some examples of this custom GPT at work.</p>\n<h2>SumGPT</h2>\n<p>The first experiment is <a href=\"https://github.com/T-vaccari/SumGPT\" rel=\"noopener noreferrer\">SumGPT</a>, a small model trained to sum two three-digit numbers. The input has a format like <code>123+456=</code> and the model has to generate the result followed by a newline. I decided to generate the digits of the result in reverse order, because this aligns the carry operation with the autoregressive direction of the model: it can start from the units and then move to tens, hundreds and thousands.</p>\n<p>For this task I used a deliberately small configuration, with an embedding size of 128, 4 attention heads, 4 transformer blocks and a context length of 12. The complete operation fits inside this context, so there is no reason to use a larger model. On my GPU the training took around three minutes, and on 1000 randomly generated sums it reached around 99% accuracy.</p>\n<p>An interesting thing that I noticed during training was that the loss was not measuring only the ability to perform the sum. The operands before the <code>=</code> sign are generated randomly, so asking the model to predict them introduces a part of the loss that cannot be reduced: there is no pattern that allows it to know which random digit comes next. I fixed this by masking all targets before <code>=</code>, so the cross-entropy only measures the generated result.</p>\n<p>This is a small and constrained experiment, but it shows that the architecture can learn an algorithmic pattern and not only the statistical structure of natural language. It also shows how important it is to understand what the loss is actually measuring: a high loss does not always mean that the model is failing at the task we care about.</p>\n<h2>BookGPT</h2>\n<p>The second experiment is <a href=\"https://github.com/T-vaccari/BookGPT\" rel=\"noopener noreferrer\">BookGPT</a>, a character-level language model trained on Italian books. Here the task is more open: given a sequence of characters, the model has to predict the next one and generate prose one character at a time.</p>\n<p>The first dataset was composed of 19 books from an Hugging Face dataset, plus <em>I Promessi Sposi</em>. During the experiment I discovered that most of those books were English Gutenberg texts translated automatically into Italian. The corpus contained duplicated fragments, strange sentences and even a book with the wrong title. The model learned these artifacts: the generated text had a recognizable Italian structure, but it also produced many invented words and English names like <code>Sighbury</code> and <code>Mr. Wergomestane</code>.</p>\n<p>For this reason I rebuilt the dataset using 15 native Italian works from authors like Manzoni, Verga, Pirandello, Nievo, De Amicis, Deledda and Collodi. The final clean corpus contains around 9.48 million characters and a vocabulary of 140 characters. Every book was split independently into 90% training and 10% validation before concatenating them, to avoid having a validation set biased toward only the last books in the corpus.</p>\n<p>I trained three different configurations and compared their validation loss and generated text at matching training steps.</p>\n<p>The first one was the baseline trained on the old corpus. It had around 852K parameters, an embedding size of 128, 4 heads, 4 blocks and a context length of 256. It reached a validation loss of 1.4355 at step 5000 and 1.3416 at step 8250. Training and validation loss remained very close, so there was no clear sign of overfitting. The generated text started to reproduce Italian syntax, but the bad quality of the corpus was clearly visible in its vocabulary.</p>\n<p>The second run used the clean corpus and a larger model, with around 4.09M parameters, an embedding size of 256, 8 heads, 6 blocks and the same context length of 256. This was the best result. At step 2000 its validation loss was 1.5673, compared with 1.7487 for the baseline, and at step 4750 it reached 1.3969, compared with 1.4497. The validation loss continued to decrease, while the larger gap from the training loss stabilized instead of continuing to grow.</p>\n<p>The generated text improved too. The model started to generate names present in the corpus, such as <code>zio Trao</code> from <em>I Malavoglia</em>, and it often maintained a plausible agreement between subjects and verbs. However, the text was still not coherent for long. Subjects could disappear or change inside the same generation, and many words were still invented even if they had a plausible Italian morphology.</p>\n<p>Since the main limitation seemed to be coherence, I tried a third run with the context length increased from 256 to 512. The hypothesis was that seeing more previous characters would help the model maintain subjects and references for longer. The result was negative: at step 4750 the validation loss was 1.5241, worse than both the baseline at 1.4497 and the larger model at 1.3969. The generated text did not show a clear improvement either. With prompts like <code>Roma</code> or <code>lungo i fiumi</code>, the model produced locally plausible Italian fragments, but the sentence still lost its direction and introduced invented words.</p>\n<p>On this hardware the larger context was not free. Without an efficient attention kernel, its memory cost forced me to reduce the batch size from 128 to 64. The model was therefore learning a more difficult task with noisier gradient updates, without receiving more representational capacity. In this setup, investing the available compute in width and depth produced better results than investing it in a longer context.</p>\n<p>The main observation from BookGPT is that context length alone does not create coherence. A wider window only gives the model the possibility to look further back; it does not guarantee that it will learn how to use that information. In this experiment, a clean corpus and more model capacity had a much clearer effect than simply doubling the context length.</p>\n<h2>Conclusion</h2>\n<p>These experiments made the trade-offs of a GPT architecture much more concrete. By implementing it from scratch I also got a better grasp of its fundamental building block: a transformer block composed of causal self-attention and a feedforward network, together with residual connections and layer normalization. Deriving its computational cost also made it clear which parts scale with the model dimension and which ones become expensive as the context grows.</p>\n<p>SumGPT showed that even a very small model can learn a precise algorithmic task, but only if the training objective measures the right thing. BookGPT showed that for language modeling the architecture is only one part of the problem: cleaning the dataset and increasing useful model capacity improved the results more than simply giving the model a longer context.</p>\n<p>The longer-context run was probably the most useful negative result. A larger context window does not automatically produce better coherence, especially when it forces a smaller batch size and the model does not have enough capacity to use the additional information. On this hardware and at this scale, a context of 256 with a wider and deeper model was a better allocation of compute than a context of 512.</p>\n<p>The generated text is still far from coherent prose, but that is consistent with the size of the model and the character-level setup. The useful result was not producing a good language model; it was understanding which changes actually improved it, which ones did not, and why.</p>","date_published":"2026-07-06T00:00:00.000Z","tags":["Neural Networks","Deep Learning","Transformers","Attention","Python"]},{"id":"https://www.tommasovaccari.com/blog/introduction-to-blas","url":"https://www.tommasovaccari.com/blog/introduction-to-blas","title":"Introduction to BLAS","summary":"An introduction to BLAS routines, the roofline model, and SGEMM optimization","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<p>While reasoning on some ML concepts I just stumbled on an interesting question, who and what is providing fast low level APIs, for example for computing matmul etc?</p>\n<p>The answer happened to lie in the BLAS interface definition, the Basic Linear Algebra Subprograms. BLAS defines a standard set of routines for common linear-algebra operations, while libraries such as Apple Accelerate, Intel oneMKL, OpenBLAS, cuBLAS,and BLIS provide optimized implementations. Netlib also provides reference implementations, which prioritize portability and correctness rather than peak performance. As you can see hardware vendors usually ship also their BLAS implementation taylored for the hardware.</p>\n<p>BLAS was originally specified through Fortran routines. C programs can call the same operations through CBLAS, a C interface with conventions that are more natural in C, such as explicit row-major and column-major layouts.</p>\n<p>High-level machine-learning frameworks rely heavily on operations such as matrix multiplication. Their performance depends on optimized numerical kernels supplied by the framework, hardware vendor, or an external library. These notes grew out of studying BLAS and the roofline model, with the practical goal of writing a custom SGEMM kernel and comparing it with Apple Accelerate's <code>cblas_sgemm</code>.</p>\n<p>The complete implementation used in this article is available in the <a href=\"https://github.com/T-vaccari/custom-blas/tree/main\" rel=\"noopener noreferrer\">custom-blas repository</a>.</p>\n<h2>Operations offered by BLAS</h2>\n<p>BLAS routines are divided into three levels. For dense square inputs of dimension <code class=\"formula-inline\">n</code>, their characteristic computational costs are:</p>\n<table><thead><tr><th>Level</th><th>Main operation</th><th>Example</th><th>Computational cost</th></tr></thead><tbody><tr><td>1</td><td>Vector-vector</td><td>AXPY</td><td><code>O(n)</code></td></tr><tr><td>2</td><td>Matrix-vector</td><td>GEMV</td><td><code>O(n^2)</code></td></tr><tr><td>3</td><td>Matrix-matrix</td><td>GEMM</td><td><code>O(n^3)</code></td></tr></tbody></table>\n<p>I am mainly interested in the third category: matrix-matrix multiplication. The conventional algorithm has a time complexity of <code class=\"formula-inline\">O(n^3)</code>. Asymptotically faster algorithms exist: <a href=\"https://en.wikipedia.org/wiki/Strassen_algorithm\" rel=\"noopener noreferrer\">Strassen's algorithm</a>, for example, runs in <code class=\"formula-inline\">O(n^{\\log_2 7}) \\approx O(n^{2.807})</code>. In practice, conventional blocked GEMM is usually preferred for ordinary matrix sizes because it has smaller constants, better numerical properties, and maps efficiently to modern memory hierarchies. Some libraries use Strassen-like methods selectively for sufficiently large matrices.</p>\n<p>BLAS routine names encode the data type and operation. Common precision prefixes are:</p>\n<table><thead><tr><th>Prefix</th><th>Data type</th></tr></thead><tbody><tr><td><code>S</code></td><td>Single-precision real (<code>float</code>)</td></tr><tr><td><code>D</code></td><td>Double-precision real (<code>double</code>)</td></tr><tr><td><code>C</code></td><td>Single-precision complex</td></tr><tr><td><code>Z</code></td><td>Double-precision complex</td></tr></tbody></table>\n<p>Many matrix routines then include a code describing the matrix structure and operation:</p>\n<table><thead><tr><th>Code</th><th>Meaning</th></tr></thead><tbody><tr><td><code>GE</code></td><td>General matrix</td></tr><tr><td><code>SY</code></td><td>Symmetric matrix</td></tr><tr><td><code>TR</code></td><td>Triangular matrix</td></tr><tr><td><code>GB</code></td><td>General band matrix</td></tr><tr><td><code>MV</code></td><td>Matrix-vector operation</td></tr><tr><td><code>MM</code></td><td>Matrix-matrix operation</td></tr></tbody></table>\n<p>For example, <code>SGEMM</code> means single-precision general matrix-matrix multiplication.</p>\n<p>The Netlib documentation describes both the <a href=\"https://www.netlib.org/lapack/explore-html/db/d66/cblas__sgemm_8c_a47f5a6957067c3031e4b751eb6532ea7.html#a47f5a6957067c3031e4b751eb6532ea7\" rel=\"noopener noreferrer\"><code>cblas_sgemm</code></a> and <a href=\"https://netlib.org/lapack/explore-html/dd/d09/group__gemm_ga8cad871c590600454d22564eff4fed6b.html#ga8cad871c590600454d22564eff4fed6b\" rel=\"noopener noreferrer\">Fortran <code>SGEMM</code></a> interfaces.</p>\n<h2>Roofline model</h2>\n<p>The roofline model helps determine whether a kernel is limited by compute throughput or memory bandwidth. It uses the following quantities, that we can define:</p>\n<ul>\n<li><strong>Work, <code class=\"formula-inline\">W</code>:</strong> the number of floating-point operations performed, measured in FLOPs.</li>\n<li><strong>Memory traffic, <code class=\"formula-inline\">Q</code>:</strong> the number of bytes transferred between the relevant levels of the memory hierarchy.</li>\n<li><strong>Arithmetic intensity, <code class=\"formula-inline\">I</code>:</strong> the ratio <code class=\"formula-inline\">W/Q</code>, measured in FLOPs per byte.</li>\n<li><strong>Performance, <code class=\"formula-inline\">P</code>:</strong> the achieved rate of computation, measured in FLOP/s.</li>\n</ul>\n<p>For a given architecture, let <code class=\"formula-inline\">P_{\\text{peak}}</code> be its peak floating-point throughput and <code class=\"formula-inline\">B_{\\text{peak}}</code> its peak memory bandwidth. The roofline bound is</p>\n<div class=\"formula\"><code>P \\leq \\min\\left(P_{\\text{peak}}, B_{\\text{peak}} I\\right).</code></div>\n<p><img src=\"https://www.tommasovaccari.com/static/image-1a64c4ae.webp\" alt=\"Roofline model\" /></p>\n<p>The intersection between the horizontal compute roof and the diagonal bandwidth roof is the <strong>ridge point</strong>:</p>\n<div class=\"formula\"><code>I_{\\text{ridge}} = \\frac{P_{\\text{peak}}}{B_{\\text{peak}}}.</code></div>\n<p>Kernels to the left of this point are bandwidth-bound under the model; kernels to the right are compute-bound.</p>\n<p>For an AXPY-like vector operation, both the work and memory traffic scale as <code class=\"formula-inline\">O(n)</code>, so arithmetic intensity remains <code class=\"formula-inline\">O(1)</code>. Dense matrix-vector multiplication performs <code class=\"formula-inline\">O(n^2)</code> work but must also read <code class=\"formula-inline\">O(n^2)</code> matrix elements, so its intensity is also <code class=\"formula-inline\">O(1)</code>.</p>\n<p>Dense matrix-matrix multiplication performs <code class=\"formula-inline\">O(n^3)</code> work on <code class=\"formula-inline\">O(n^2)</code> input and output data. With a blocked implementation that reuses data effectively, its arithmetic intensity can grow as <code class=\"formula-inline\">O(n)</code>. This reuse is why GEMM can move into the compute-bound region. A naive loop nest does not automatically achieve the minimum possible memory traffic, so its realized intensity may be much lower.</p>\n<h2>Writing SGEMM from scratch</h2>\n<p>The goal is to implement several simplified SGEMM variants and compare them with Apple Accelerate:</p>\n<ul>\n<li>Naive <code>i-j-k</code> loop order</li>\n<li>Reorganized <code>i-k-j</code> loop order with vectorization disabled</li>\n<li>Reorganized <code>i-k-j</code> loop order with vectorization enabled</li>\n</ul>\n<p>The standard CBLAS signature is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>void</span><span> cblas_sgemm</span><span>(</span></span>\n<span class=\"line\"><span>    CBLAS_ORDER </span><span>Order</span><span>,</span></span>\n<span class=\"line\"><span>    CBLAS_TRANSPOSE </span><span>TransA</span><span>,</span></span>\n<span class=\"line\"><span>    CBLAS_TRANSPOSE </span><span>TransB</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> M</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> N</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> K</span><span>,</span></span>\n<span class=\"line\"><span>    float</span><span> alpha</span><span>,</span></span>\n<span class=\"line\"><span>    const</span><span> float</span><span> *</span><span>A</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> lda</span><span>,</span></span>\n<span class=\"line\"><span>    const</span><span> float</span><span> *</span><span>B</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> ldb</span><span>,</span></span>\n<span class=\"line\"><span>    float</span><span> beta</span><span>,</span></span>\n<span class=\"line\"><span>    float</span><span> *</span><span>C</span><span>,</span></span>\n<span class=\"line\"><span>    int</span><span> ldc</span></span>\n<span class=\"line\"><span>);</span></span></code></pre>\n<p>It computes</p>\n<div class=\"formula\"><code>C \\leftarrow \\alpha AB + \\beta C.</code></div>\n<p>The parameters have the following roles:</p>\n<ul>\n<li><code>Order</code> selects row-major or column-major storage. C supports both; this experiment uses <code>CblasRowMajor</code>.</li>\n<li><code>TransA</code> and <code>TransB</code> specify whether each input is used as-is or transposed.</li>\n<li><code>M</code>, <code>N</code>, and <code>K</code> define the matrix dimensions: <code class=\"formula-inline\">A</code> is <code class=\"formula-inline\">M \\times K</code>, <code class=\"formula-inline\">B</code> is <code class=\"formula-inline\">K \\times N</code>, and <code class=\"formula-inline\">C</code> is <code class=\"formula-inline\">M \\times N</code> when neither input is transposed.</li>\n<li><code>alpha</code> scales <code class=\"formula-inline\">AB`, while `beta` scales the previous value of </code>C$.</li>\n<li><code>lda</code>, <code>ldb</code>, and <code>ldc</code> are the leading dimensions. For contiguous row-major matrices without padding, they are respectively <code>K</code>, <code>N</code>, and <code>N</code> in the non-transposed case.</li>\n</ul>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>A         ×  B         =  C</span></span>\n<span class=\"line\"><span>(M × K)      (K × N)      (M × N)</span></span></code></pre>\n<p>To keep the experiment focused, every custom implementation supports only square, row-major, non-transposed matrices and is called with <code class=\"formula-inline\">\\alpha = 1</code> and <code class=\"formula-inline\">\\beta = 0</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>cblas_sgemm</span><span>(</span></span>\n<span class=\"line\"><span>    CblasRowMajor, CblasNoTrans, CblasNoTrans,</span></span>\n<span class=\"line\"><span>    n, n, n,</span></span>\n<span class=\"line\"><span>    1.0</span><span>f</span><span>,</span></span>\n<span class=\"line\"><span>    A, n,</span></span>\n<span class=\"line\"><span>    B, n,</span></span>\n<span class=\"line\"><span>    0.0</span><span>f</span><span>,</span></span>\n<span class=\"line\"><span>    C, n</span></span>\n<span class=\"line\"><span>);</span></span></code></pre>\n<p>The custom functions mimic the CBLAS parameter list but do not implement the complete <code>cblas_sgemm</code> contract.</p>\n<h3>Naive version</h3>\n<p>The naive implementation computes the dot product between each row of <code class=\"formula-inline\">A</code> and each column of <code class=\"formula-inline\">B</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>for</span><span> (</span><span>size_t</span><span> i </span><span>=</span><span> 0</span><span>; i </span><span>&lt;</span><span> N; i</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>    for</span><span> (</span><span>size_t</span><span> j </span><span>=</span><span> 0</span><span>; j </span><span>&lt;</span><span> N; j</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>        float</span><span> acc </span><span>=</span><span> 0.0</span><span>f</span><span>;</span></span>\n<span class=\"line\"><span>        for</span><span> (</span><span>size_t</span><span> k </span><span>=</span><span> 0</span><span>; k </span><span>&lt;</span><span> N; k</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>            acc </span><span>+=</span><span> *</span><span>(A </span><span>+</span><span> lda </span><span>*</span><span> i </span><span>+</span><span> k) </span><span>*</span><span> *</span><span>(B </span><span>+</span><span> ldb </span><span>*</span><span> k </span><span>+</span><span> j);</span></span>\n<span class=\"line\"><span>        }</span></span>\n<span class=\"line\"><span>        *</span><span>(C </span><span>+</span><span> ldc </span><span>*</span><span> i </span><span>+</span><span> j) </span><span>=</span><span> acc;</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The accesses to <code class=\"formula-inline\">A</code> are contiguous, but the accesses to <code class=\"formula-inline\">B</code> use a stride of <code>ldb</code>. This is inefficient for row-major storage because consecutive iterations touch different rows of <code class=\"formula-inline\">B</code>.leading to cache misses, that are very costly.</p>\n<h3>Reorganized version</h3>\n<p>Changing the loop order from <code>i-j-k</code> to <code>i-k-j</code> makes the innermost loop traverse rows of <code class=\"formula-inline\">B</code> and <code class=\"formula-inline\">C</code> contiguously:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>for</span><span> (</span><span>size_t</span><span> i </span><span>=</span><span> 0</span><span>; i </span><span>&lt;</span><span> N; i</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>    for</span><span> (</span><span>size_t</span><span> k </span><span>=</span><span> 0</span><span>; k </span><span>&lt;</span><span> N; k</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>        float</span><span> a_i_k </span><span>=</span><span> *</span><span>(A </span><span>+</span><span> lda </span><span>*</span><span> i </span><span>+</span><span> k);</span></span>\n<span class=\"line\"><span>        for</span><span> (</span><span>size_t</span><span> j </span><span>=</span><span> 0</span><span>; j </span><span>&lt;</span><span> N; j</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>            *</span><span>(C </span><span>+</span><span> ldc </span><span>*</span><span> i </span><span>+</span><span> j) </span><span>+=</span><span> a_i_k </span><span>*</span><span> *</span><span>(B </span><span>+</span><span> ldb </span><span>*</span><span> k </span><span>+</span><span> j);</span></span>\n<span class=\"line\"><span>        }</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>Because this version accumulates into <code class=\"formula-inline\">C</code>, the benchmark initializes <code class=\"formula-inline\">C</code> to zero before each run. The measured scalar variant also explicitly disables Clang's loop vectorization and unrolling, isolating the effect of loop reordering as far as this compiler configuration allows.</p>\n<h3>Vectorized version</h3>\n<p>The final custom variant keeps the same memory-access pattern and asks Clang to vectorize and interleave the inner loop:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>for</span><span> (</span><span>size_t</span><span> i </span><span>=</span><span> 0</span><span>; i </span><span>&lt;</span><span> N; i</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>    for</span><span> (</span><span>size_t</span><span> k </span><span>=</span><span> 0</span><span>; k </span><span>&lt;</span><span> N; k</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>        float</span><span> a_i_k </span><span>=</span><span> *</span><span>(A </span><span>+</span><span> lda </span><span>*</span><span> i </span><span>+</span><span> k);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        #pragma</span><span> clang</span><span> loop</span><span> vectorize</span><span>(</span><span>enable</span><span>) </span><span>interleave</span><span>(</span><span>enable</span><span>)</span></span>\n<span class=\"line\"><span>        for</span><span> (</span><span>size_t</span><span> j </span><span>=</span><span> 0</span><span>; j </span><span>&lt;</span><span> N; j</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>            *</span><span>(C </span><span>+</span><span> ldc </span><span>*</span><span> i </span><span>+</span><span> j) </span><span>+=</span><span> a_i_k </span><span>*</span><span> *</span><span>(B </span><span>+</span><span> ldb </span><span>*</span><span> k </span><span>+</span><span> j);</span></span>\n<span class=\"line\"><span>        }</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>On the Apple M2, NEON vector registers are 128 bits wide, so one vector can hold four 32-bit floats. This makes the loop suitable for SIMD, although register width alone does not predict the complete speedup: instruction scheduling, fused multiply-add throughput, memory traffic, and compiler decisions also matter.</p>\n<h2>Results</h2>\n<p>The benchmark produced the following results on an Apple M2:</p>\n<table><thead><tr><th>Implementation</th><th><code>N = 1024</code></th><th><code>N = 2048</code></th></tr></thead><tbody><tr><td>Naive (<code>i-j-k</code>)</td><td>1.72 GFLOP/s</td><td>0.92 GFLOP/s</td></tr><tr><td>Reorganized, scalar (<code>i-k-j</code>, no auto-vectorization)</td><td>6.37 GFLOP/s</td><td>6.38 GFLOP/s</td></tr><tr><td>Reorganized and vectorized (<code>i-k-j</code>, SIMD)</td><td>27.33 GFLOP/s</td><td>26.54 GFLOP/s</td></tr><tr><td>Accelerate <code>cblas_sgemm</code></td><td>789.81 GFLOP/s</td><td>1035.06 GFLOP/s</td></tr></tbody></table>\n<p>At <code class=\"formula-inline\">N = 1024</code>, loop reordering improves performance from 1.72 to 6.37 GFLOP/s, a gain of approximately <code class=\"formula-inline\">3.7\\times</code>. The main difference is the access pattern: the naive version reads <code class=\"formula-inline\">B</code> column by column with stride <code>ldb</code>, whereas the reorganized version accesses <code class=\"formula-inline\">B</code> and <code class=\"formula-inline\">C</code> sequentially in the inner loop.</p>\n<p>Enabling vectorization raises performance from 6.37 to 27.33 GFLOP/s, approximately another <code class=\"formula-inline\">4.3\\times</code>. This is consistent with SIMD being effective on the contiguous inner loop, but it should not be interpreted as proof that the speedup comes exclusively from processing four floats per vector instruction.</p>\n<p>The total improvement from the naive to the vectorized custom version is approximately <code class=\"formula-inline\">15.9\\times</code>. Accelerate remains much faster: about <code class=\"formula-inline\">29\\times</code> faster at <code class=\"formula-inline\">N = 1024</code> and <code class=\"formula-inline\">39\\times</code> faster at <code class=\"formula-inline\">N = 2048</code>.</p>\n<p>This remaining gap cannot be closed by a vectorization pragma alone. High-performance GEMM implementations typically combine several techniques:</p>\n<ul>\n<li><strong>Cache blocking:</strong> partitioning matrices into tiles so that data is reused while it remains in a nearby cache.</li>\n<li><strong>Register blocking and microkernels:</strong> keeping a small output tile in registers across many multiply-add operations.</li>\n<li><strong>Parallelism and architecture-specific kernels:</strong> distributing work across cores and using instructions or matrix hardware tailored to the processor.</li>\n</ul>\n<h2>Conclusion</h2>\n<p>BLAS routines are fundamental building blocks for scientific computing and machine learning. This experiment shows that two understandable changes—improving memory access and enabling SIMD—can produce a substantial speedup, while also demonstrating how much additional engineering separates a simple loop nest from a production GEMM implementation.</p>\n<p>The next step is to add cache blocking and a register-level microkernel, then evaluate them with a more rigorous benchmark harness.</p>","date_published":"2026-06-27T00:00:00.000Z","tags":["HPC","Linear Algebra","C"]},{"id":"https://www.tommasovaccari.com/blog/attention-mechanism-to-small-gpt","url":"https://www.tommasovaccari.com/blog/attention-mechanism-to-small-gpt","title":"From Self-Attention to a Small GPT","summary":"A personal from-scratch derivation of causal self-attention, starting from a character language model and ending with the core pieces of a small GPT.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2>Introduction</h2>\n<p>This post is a continuation of my notes on language modeling, adapted from the notes that I took while studying the topic.\nThe goal is to start from the simplest character-level model, as explained in other posts, and then build the core ideas that lead to a small GPT-style transformer.</p>\n<p>The main focus is the attention mechanism: why it is needed, how causal self-attention works, and how it fits inside a transformer block.</p>\n<p>The derivation is grounded in the scaled dot-product and multi-head attention mechanisms introduced by Vaswani et al. in <a href=\"https://arxiv.org/abs/1706.03762\" rel=\"noopener noreferrer\">Attention Is All You Need</a>.</p>\n<p>The path is:</p>\n<ol>\n<li>tokenization and the next-character prediction task</li>\n<li>batching fixed-length context windows</li>\n<li>the bigram model as the simplest language model</li>\n<li>why tokens need to communicate</li>\n<li>causal averaging as the first intuition for attention</li>\n<li>query, key, and value vectors</li>\n<li>scaled dot-product self-attention</li>\n<li>multi-head attention</li>\n<li>feed-forward layers, residual connections, dropout, and layer normalization</li>\n</ol>\n<p>The objective is to understand what each piece is doing.</p>\n<h2>Language Modeling Setup</h2>\n<p>A language model predicts the next token given the previous tokens:</p>\n<div class=\"formula\"><code>P(x_t \\mid x_1, x_2, \\ldots, x_{t-1})</code></div>\n<p>In this project the model is character-based, so every character is mapped to an integer. This keeps the vocabulary small and makes the implementation easier to inspect.</p>\n<p>There are more powerful tokenizers, for example:</p>\n<ul>\n<li>Google's one: <a href=\"https://github.com/google/sentencepiece\" rel=\"noopener noreferrer\">SentencePiece</a></li>\n<li>OpenAI's one: <a href=\"https://github.com/openai/tiktoken\" rel=\"noopener noreferrer\">tiktoken</a></li>\n</ul>\n<p>but for a first implementation a character-level tokenizer is enough.</p>\n<p>If the vocabulary is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>chars </span><span>=</span><span> sorted</span><span>(</span><span>list</span><span>(</span><span>set</span><span>(text)))</span></span>\n<span class=\"line\"><span>stoi </span><span>=</span><span> {ch: i </span><span>for</span><span> i, ch </span><span>in</span><span> enumerate</span><span>(chars)}</span></span>\n<span class=\"line\"><span>itos </span><span>=</span><span> {i: ch </span><span>for</span><span> ch, i </span><span>in</span><span> stoi.items()}</span></span></code></pre>\n<p>then encoding and decoding are just table lookups:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>encode </span><span>=</span><span> lambda</span><span> s: [stoi[c] </span><span>for</span><span> c </span><span>in</span><span> s]</span></span>\n<span class=\"line\"><span>decode </span><span>=</span><span> lambda</span><span> xs: </span><span>\"\"</span><span>.join(itos[i] </span><span>for</span><span> i </span><span>in</span><span> xs)</span></span></code></pre>\n<h2>Words and Vectors</h2>\n<p>The model never sees strings directly. It sees tensors of integer token ids.</p>\n<p>This is the first important shift: for the model, characters are not characters. They are numbers. But a raw integer id is not a good\nrepresentation by itself, because the number <code>10</code> is not “more character” than the number <code>3</code>. The id is only an index.</p>\n<p>For this reason we use an embedding table. Each token id selects a learned vector, and that vector becomes the internal representation\nof the token.</p>\n<p>As humans, we have a strong geometric intuition only up to two or three dimensions. In deep learning, instead, we work with high-\ndimensional spaces. The idea is that a vector with many dimensions can encode many useful features at the same time.</p>\n<p>In a character-level model these features are not necessarily human-readable meanings. The model may learn that some characters behave\nlike vowels, that some often appear after others, or that some are common near word endings. The important point is that the\nrepresentation is learned from data.</p>\n<p>A good way to build intuition for this is to look at pretrained word embeddings. In that case, words with similar meaning often end up\nclose in the embedding space. Classic examples show that relationships such as <code>king - man + woman</code> can point near <code>queen</code>.</p>\n<p>Our model is much smaller and works at character level, but the principle is the same: instead of treating tokens as isolated symbols,\nwe map them into vectors that the neural network can modify, combine, and use for prediction.</p>\n<p>Before seeing an example, we need another important concept from linear algebra: the standard dot product, also known as the standard inner product.</p>\n<p>When we work with a vector space, we can associate it with an inner product, which is a function that takes two vectors and returns a real number:</p>\n<div class=\"formula\"><code>\\langle \\cdot, \\cdot \\rangle : V \\times V \\to \\mathbb{R}</code></div>\n<p>An inner product must satisfy some properties:</p>\n<ul>\n<li>it is linear in its arguments</li>\n<li>it is symmetric</li>\n<li>it is positive definite, so <code class=\"formula-inline\">\\langle v, v \\rangle \\ge 0</code></li>\n<li><code class=\"formula-inline\">\\langle v, v \\rangle = 0</code> only when <code class=\"formula-inline\">v = 0</code></li>\n</ul>\n<p>When we work in the standard space <code class=\"formula-inline\">\\mathbb{R}^n</code>, the standard dot product between two vectors</p>\n<div class=\"formula\"><code>a = (a_1, a_2, \\ldots, a_n)</code></div>\n<p>and</p>\n<div class=\"formula\"><code>b = (b_1, b_2, \\ldots, b_n)</code></div>\n<p>is defined as:</p>\n<div class=\"formula\"><code>a \\cdot b = \\sum_{i=1}^{n} a_i b_i</code></div>\n<p>So we multiply the entries in the same position and then we sum everything.</p>\n<p>There is also a geometric interpretation. If <code class=\"formula-inline\">\\|a\\|</code> and <code class=\"formula-inline\">\\|b\\|</code> are the lengths of the two vectors, then:</p>\n<div class=\"formula\"><code>a \\cdot b = \\|a\\| \\|b\\| \\cos(\\theta)</code></div>\n<p>where <code class=\"formula-inline\">\\theta</code> is the angle between the two vectors.</p>\n<p>This is important for embeddings because now we have a way to compare two vectors. If two embedding vectors point in a similar direction, then intuitively they are carrying similar information. If they point in very different directions, then they are representing something different.</p>\n<p>The problem is that the raw dot product also depends on the length of the vectors. So if we want to focus only on the direction, we use cosine similarity:</p>\n<div class=\"formula\"><code>\\mathrm{cosine\\_similarity}(a,b) =\n\\frac{a \\cdot b}{\\|a\\|\\|b\\|}</code></div>\n<p>The value is between <code class=\"formula-inline\">-1</code> and <code class=\"formula-inline\">1</code>:</p>\n<ul>\n<li>if it is close to <code class=\"formula-inline\">1</code>, the two vectors point in a similar direction</li>\n<li>if it is close to <code class=\"formula-inline\">0</code>, the two vectors are almost orthogonal</li>\n<li>if it is close to <code class=\"formula-inline\">-1</code>, the two vectors point in opposite directions</li>\n</ul>\n<p>For embeddings, this gives us a practical measure of similarity. If two words have similar meaning, we expect their vectors to have a high cosine similarity.</p>\n<p><img src=\"https://www.tommasovaccari.com/static/cosine-similarity-vectors-cc32c236.webp\" alt=\"Cosine similarity vectors\" /></p>\n<p>In the next example, we can use pretrained GloVe embeddings and test this directly with PyTorch.</p>\n<p>Reference: <a href=\"https://nlp.stanford.edu/projects/glove/\" rel=\"noopener noreferrer\">GloVe: Global Vectors for Word Representation</a>.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>glove </span><span>=</span><span> GloVe(</span><span>name</span><span>=</span><span>\"6B\"</span><span>, </span><span>dim</span><span>=</span><span>100</span><span>)</span><span>;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>def</span><span> cosine</span><span>(a, b):</span></span>\n<span class=\"line\"><span>    return</span><span> F.cosine_similarity(glove[a], glove[b], </span><span>dim</span><span>=</span><span>0</span><span>).item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"king / queen:\"</span><span>, cosine(</span><span>\"king\"</span><span>, </span><span>\"queen\"</span><span>))</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"king / man:\"</span><span>, cosine(</span><span>\"king\"</span><span>, </span><span>\"man\"</span><span>))</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"queen / woman:\"</span><span>, cosine(</span><span>\"queen\"</span><span>, </span><span>\"woman\"</span><span>))</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"man / woman:\"</span><span>, cosine(</span><span>\"man\"</span><span>, </span><span>\"woman\"</span><span>))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>target </span><span>=</span><span> glove[</span><span>\"king\"</span><span>] </span><span>-</span><span> glove[</span><span>\"man\"</span><span>] </span><span>+</span><span> glove[</span><span>\"woman\"</span><span>]</span></span>\n<span class=\"line\"><span># Compute the cosine similarity between the target vector and all vectors</span></span>\n<span class=\"line\"><span># in the GloVe vocabulary</span></span>\n<span class=\"line\"><span>scores </span><span>=</span><span> F.cosine_similarity(target.unsqueeze(</span><span>0</span><span>), glove.vectors)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>exclude </span><span>=</span><span> {</span><span>\"king\"</span><span>, </span><span>\"man\"</span><span>, </span><span>\"woman\"</span><span>}</span></span>\n<span class=\"line\"><span>for</span><span> word </span><span>in</span><span> exclude:</span></span>\n<span class=\"line\"><span>   scores[glove.stoi[word]] </span><span>=</span><span> -</span><span>1</span></span>\n<span class=\"line\"><span># Now we can see if it's effectively close to \"queen\"</span></span>\n<span class=\"line\"><span>best_score, best_idx </span><span>=</span><span> torch.topk(scores, </span><span>k</span><span>=</span><span>5</span><span>)</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"Top 5 closest words to 'king - man + woman':\"</span><span>)</span></span>\n<span class=\"line\"><span>for</span><span> i </span><span>in</span><span> range</span><span>(</span><span>5</span><span>):</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>\"  </span><span>{</span><span>glove.itos[best_idx[i]]</span><span>}</span><span>: </span><span>{</span><span>best_score[i].item()</span><span>:.4f</span><span>}</span><span>\"</span><span>)</span></span></code></pre>\n<p>This produces:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>Top 5 closest words to 'king - man + woman':</span></span>\n<span class=\"line\"><span>  queen: 0.7834</span></span>\n<span class=\"line\"><span>  monarch: 0.6934</span></span>\n<span class=\"line\"><span>  throne: 0.6833</span></span>\n<span class=\"line\"><span>  daughter: 0.6809</span></span>\n<span class=\"line\"><span>  prince: 0.6713</span></span></code></pre>\n<h2>Context Windows and Batches</h2>\n<p>When training a transformer, we do not push the entire dataset into the model at once. We sample fixed-length chunks.</p>\n<p>If <code>block_size = 8</code>, then each row of the batch contains 8 input tokens and 8 target tokens. The target sequence is just the input sequence shifted by one position:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span>=</span><span> data[i:i </span><span>+</span><span> block_size]</span></span>\n<span class=\"line\"><span>y </span><span>=</span><span> data[i </span><span>+</span><span> 1</span><span>:i </span><span>+</span><span> block_size </span><span>+</span><span> 1</span><span>]</span></span></code></pre>\n<p>So for one row:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x: [18, 47, 56, 57, 1, 58, 46, 43]</span></span>\n<span class=\"line\"><span>y: [47, 56, 57, 1, 58, 46, 43, 1]</span></span></code></pre>\n<p>This contains multiple training examples at the same time. At position <code>t</code>, the model should predict <code>y[t]</code> using only <code>x[:t+1]</code>.</p>\n<p>The batch dimension allows parallelization:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>def</span><span> get_batch</span><span>(split):</span></span>\n<span class=\"line\"><span>    data </span><span>=</span><span> train_data </span><span>if</span><span> split </span><span>==</span><span> \"train\"</span><span> else</span><span> val_data</span></span>\n<span class=\"line\"><span>    ix </span><span>=</span><span> torch.randint(</span><span>len</span><span>(data) </span><span>-</span><span> block_size, (batch_size,))</span></span>\n<span class=\"line\"><span>    x </span><span>=</span><span> torch.stack([data[i:i </span><span>+</span><span> block_size] </span><span>for</span><span> i </span><span>in</span><span> ix])</span></span>\n<span class=\"line\"><span>    y </span><span>=</span><span> torch.stack([data[i </span><span>+</span><span> 1</span><span>:i </span><span>+</span><span> block_size </span><span>+</span><span> 1</span><span>] </span><span>for</span><span> i </span><span>in</span><span> ix])</span></span>\n<span class=\"line\"><span>    return</span><span> x, y</span></span></code></pre>\n<p>The shape is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x: (B, T)</span></span>\n<span class=\"line\"><span>y: (B, T)</span></span></code></pre>\n<p>where <code>B</code> is the batch size and <code>T</code> is the context length.</p>\n<h2>The Bigram Baseline</h2>\n<p>The simplest neural language model is a bigram model.</p>\n<p>It predicts the next token using only the current token:</p>\n<div class=\"formula\"><code>P(x_{t+1} \\mid x_t)</code></div>\n<p>In PyTorch this can be implemented with an embedding table of shape:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>vocab_size x vocab_size</span></span></code></pre>\n<p>When we pass an integer token id, the embedding table returns the corresponding row. That row contains the logits for the next character.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> BigramLanguageModel</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, vocab_size):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.token_embedding_table </span><span>=</span><span> nn.Embedding(vocab_size, vocab_size)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, idx, targets</span><span>=</span><span>None</span><span>):</span></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> self</span><span>.token_embedding_table(idx)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        if</span><span> targets </span><span>is</span><span> None</span><span>:</span></span>\n<span class=\"line\"><span>            return</span><span> logits, </span><span>None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        B, T, C </span><span>=</span><span> logits.shape</span></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> logits.view(B </span><span>*</span><span> T, C)</span></span>\n<span class=\"line\"><span>        targets </span><span>=</span><span> targets.view(B </span><span>*</span><span> T)</span></span>\n<span class=\"line\"><span>        loss </span><span>=</span><span> F.cross_entropy(logits, targets)</span></span>\n<span class=\"line\"><span>        return</span><span> logits, loss</span></span></code></pre>\n<p>The loss is cross entropy. Internally, cross entropy applies softmax to convert logits into probabilities and then computes the negative log likelihood of the correct target.</p>\n<p>If the target probability is high, the loss is low. If the target probability is low, the loss is high.</p>\n<h2>Generation</h2>\n<p>Generation repeatedly asks the model for the next-token distribution and samples from it.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>def</span><span> generate</span><span>(self, idx, max_new_tokens):</span></span>\n<span class=\"line\"><span>    for</span><span> _ </span><span>in</span><span> range</span><span>(max_new_tokens):</span></span>\n<span class=\"line\"><span>        logits, _ </span><span>=</span><span> self</span><span>(idx)</span></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> logits[:, </span><span>-</span><span>1</span><span>, :]</span></span>\n<span class=\"line\"><span>        probs </span><span>=</span><span> F.softmax(logits, </span><span>dim</span><span>=-</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        idx_next </span><span>=</span><span> torch.multinomial(probs, </span><span>num_samples</span><span>=</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        idx </span><span>=</span><span> torch.cat((idx, idx_next), </span><span>dim</span><span>=</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>    return</span><span> idx</span></span></code></pre>\n<p>At this stage, looking only at the last time step seems wasteful because the model computes logits for all positions. But this structure becomes useful once the model starts using the whole context.</p>\n<h2>Why Tokens Need to Communicate</h2>\n<p>The bigram model has a hard limitation: every token is predicted from one previous token only.</p>\n<p>This is not enough for language. The meaning of a token depends on the previous context. A token should be able to receive information from previous tokens.</p>\n<p>The constraint is causal:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>past tokens -&gt; current token</span></span>\n<span class=\"line\"><span>future tokens are hidden</span></span></code></pre>\n<p>So information can flow from left to right, but not from right to left.</p>\n<p>The simplest way to let tokens communicate is to average the previous token vectors.</p>\n<p>If <code>x</code> has shape:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>(B, T, C)</span></span></code></pre>\n<p>then for each position <code>t</code>, we want:</p>\n<div class=\"formula\"><code>\\text{out}_{b,t} = \\frac{1}{t+1}\\sum_{i=0}^{t} x_{b,i}</code></div>\n<p>This gives each token a \"bag of previous words\" representation.</p>\n<h2>Causal Averaging with Matrix Multiplication</h2>\n<p>The naive implementation is a double loop:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>out </span><span>=</span><span> torch.zeros_like(x)</span></span>\n<span class=\"line\"><span>for</span><span> b </span><span>in</span><span> range</span><span>(B):</span></span>\n<span class=\"line\"><span>    for</span><span> t </span><span>in</span><span> range</span><span>(T):</span></span>\n<span class=\"line\"><span>        xprev </span><span>=</span><span> x[b, :t </span><span>+</span><span> 1</span><span>]</span></span>\n<span class=\"line\"><span>        out[b, t] </span><span>=</span><span> torch.mean(xprev, </span><span>0</span><span>)</span></span></code></pre>\n<p>The same operation can be written as matrix multiplication.</p>\n<p>We create a lower triangular matrix:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>tril </span><span>=</span><span> torch.tril(torch.ones(T, T))</span></span>\n<span class=\"line\"><span>wei </span><span>=</span><span> tril </span><span>/</span><span> tril.sum(</span><span>1</span><span>, </span><span>keepdim</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>out </span><span>=</span><span> wei </span><span>@</span><span> x</span></span></code></pre>\n<p>The matrix <code>wei</code> contains the averaging weights. Since it is lower triangular, each token can only aggregate information from itself and from previous tokens.</p>\n<p>This is the first intuition for attention: each token aggregates information from previous tokens using a weight matrix.</p>\n<p>The limitation is that these weights are fixed. They do not depend on the actual content of the tokens.</p>\n<h2>From Fixed Weights to Data-Dependent Weights</h2>\n<p>Self-attention makes the aggregation weights data-dependent.</p>\n<p>Each token starts with a learnable private representation <code>x</code> that contains:</p>\n<ul>\n<li>token identity</li>\n<li>positional information</li>\n</ul>\n<p>Then each token produces three vectors:</p>\n<ul>\n<li><strong>query</strong>: what this token is looking for</li>\n<li><strong>key</strong>: what this token contains when another token looks at it</li>\n<li><strong>value</strong>: what this token will communicate</li>\n</ul>\n<p>In code:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>key </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"><span>query </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"><span>value </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span></code></pre>\n<p>Given:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span># (B, T, C)</span></span>\n<span class=\"line\"><span>q </span><span>=</span><span> query(x) </span><span># (B, T, head_size)</span></span>\n<span class=\"line\"><span>k </span><span>=</span><span> key(x)   </span><span># (B, T, head_size)</span></span>\n<span class=\"line\"><span>v </span><span>=</span><span> value(x) </span><span># (B, T, head_size)</span></span></code></pre>\n<p>we compute the affinity between tokens using the dot product between queries and keys:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>wei </span><span>=</span><span> q </span><span>@</span><span> k.transpose(</span><span>-</span><span>2</span><span>, </span><span>-</span><span>1</span><span>)</span></span></code></pre>\n<p>The result has shape:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>(B, T, T)</span></span></code></pre>\n<p>For every token at position <code>i</code>, <code>wei[i, j]</code> tells how much token <code>i</code> wants to receive from token <code>j</code>.</p>\n<h2>Causal Masking</h2>\n<p>In language modeling, token <code>i</code> cannot receive information from future tokens.</p>\n<p>So we mask the upper triangular part of the attention matrix:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>wei </span><span>=</span><span> wei.masked_fill(tril </span><span>==</span><span> 0</span><span>, </span><span>float</span><span>(</span><span>\"-inf\"</span><span>))</span></span>\n<span class=\"line\"><span>wei </span><span>=</span><span> F.softmax(wei, </span><span>dim</span><span>=-</span><span>1</span><span>)</span></span></code></pre>\n<p>After softmax, every row is a probability distribution over the allowed previous positions.</p>\n<p>The output is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>out </span><span>=</span><span> wei </span><span>@</span><span> v</span></span></code></pre>\n<p>So every token receives a weighted sum of the value vectors of previous tokens.</p>\n<h2>Why We Scale by the Head Size</h2>\n<p>For a fixed pair of tokens <code>i, j</code>, the raw attention score is:</p>\n<div class=\"formula\"><code>\\mathrm{score}_{i,j} = q_i \\cdot k_j = \\sum_{h=1}^{d} q_{i,h}k_{j,h}</code></div>\n<p>where:</p>\n<div class=\"formula\"><code>d = \\mathrm{head\\_size}</code></div>\n<p>Assume for intuition that:</p>\n<div class=\"formula\"><code>q_{i,h} \\sim \\mathcal{N}(0,1), \\quad k_{j,h} \\sim \\mathcal{N}(0,1)</code></div>\n<p>and that the components are independent.</p>\n<p>For each term:</p>\n<div class=\"formula\"><code>X_h = q_{i,h}k_{j,h}</code></div>\n<p>we have:</p>\n<div class=\"formula\"><code>\\mathbb{E}[X_h] = 0</code></div>\n<p>and:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(X_h) = 1</code></div>\n<p>The score is a sum of <code>d</code> independent terms:</p>\n<div class=\"formula\"><code>\\mathrm{score}_{i,j} = \\sum_{h=1}^{d} X_h</code></div>\n<p>so:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(\\mathrm{score}_{i,j}) = d</code></div>\n<p>and the standard deviation grows like:</p>\n<div class=\"formula\"><code>\\sqrt{d}</code></div>\n<p>If <code>d</code> is large, the values entering softmax can become large in magnitude. Then softmax becomes very peaky, often close to a one-hot vector.</p>\n<p>To keep the variance stable, we divide by:</p>\n<div class=\"formula\"><code>\\sqrt{d}</code></div>\n<p>The scaled attention score is:</p>\n<div class=\"formula\"><code>\\frac{q_i \\cdot k_j}{\\sqrt{d}}</code></div>\n<p>and now:</p>\n<div class=\"formula\"><code>\\mathrm{Var}\\left(\\frac{\\mathrm{score}_{i,j}}{\\sqrt{d}}\\right)\n=\n\\frac{1}{d}\\mathrm{Var}(\\mathrm{score}_{i,j})\n=\n1</code></div>\n<p>This is scaled dot-product attention.</p>\n<h2>A Single Attention Head</h2>\n<p>Putting the pieces together:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Head</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, head_size):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.key </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.query </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.value </span><span>=</span><span> nn.Linear(n_embd, head_size, </span><span>bias</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.register_buffer(</span><span>\"tril\"</span><span>, torch.tril(torch.ones(block_size, block_size)))</span></span>\n<span class=\"line\"><span>        self</span><span>.dropout </span><span>=</span><span> nn.Dropout(dropout)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        B, T, C </span><span>=</span><span> x.shape</span></span>\n<span class=\"line\"><span>        k </span><span>=</span><span> self</span><span>.key(x)</span></span>\n<span class=\"line\"><span>        q </span><span>=</span><span> self</span><span>.query(x)</span></span>\n<span class=\"line\"><span>        wei </span><span>=</span><span> q </span><span>@</span><span> k.transpose(</span><span>-</span><span>2</span><span>, </span><span>-</span><span>1</span><span>) </span><span>*</span><span> C</span><span>**-</span><span>0.5</span></span>\n<span class=\"line\"><span>        wei </span><span>=</span><span> wei.masked_fill(</span><span>self</span><span>.tril[:T, :T] </span><span>==</span><span> 0</span><span>, </span><span>float</span><span>(</span><span>\"-inf\"</span><span>))</span></span>\n<span class=\"line\"><span>        wei </span><span>=</span><span> F.softmax(wei, </span><span>dim</span><span>=-</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        wei </span><span>=</span><span> self</span><span>.dropout(wei)</span></span>\n<span class=\"line\"><span>        v </span><span>=</span><span> self</span><span>.value(x)</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> wei </span><span>@</span><span> v</span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>There is one important correction in this code: the scaling factor should use the head dimension, not the original embedding dimension. A clearer version is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>wei </span><span>=</span><span> q </span><span>@</span><span> k.transpose(</span><span>-</span><span>2</span><span>, </span><span>-</span><span>1</span><span>) </span><span>*</span><span> k.shape[</span><span>-</span><span>1</span><span>]</span><span>**-</span><span>0.5</span></span></code></pre>\n<p>The output shape is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>(B, T, head_size)</span></span></code></pre>\n<p>Each token now carries information aggregated from the previous tokens according to learned, data-dependent weights.</p>\n<h2>Multi-Head Attention</h2>\n<p>A single head gives one attention pattern.</p>\n<p>Multi-head attention runs several heads in parallel:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> MultiHeadAttention</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, num_heads, head_size):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.heads </span><span>=</span><span> nn.ModuleList([Head(head_size) </span><span>for</span><span> _ </span><span>in</span><span> range</span><span>(num_heads)])</span></span>\n<span class=\"line\"><span>        self</span><span>.proj </span><span>=</span><span> nn.Linear(num_heads </span><span>*</span><span> head_size, n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.dropout </span><span>=</span><span> nn.Dropout(dropout)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> torch.cat([h(x) </span><span>for</span><span> h </span><span>in</span><span> self</span><span>.heads], </span><span>dim</span><span>=-</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> self</span><span>.dropout(</span><span>self</span><span>.proj(out))</span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>If <code>n_embd = 384</code> and <code>num_heads = 6</code>, then each head can have:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>head_size = 384 / 6 = 64</span></span></code></pre>\n<p>The idea is that different heads can specialize in different relationships between tokens.</p>\n<h3>More Efficient Multi-Head Attention</h3>\n<p>As you can see, we are processing the heads separately. What we want to do now is process all heads in parallel, exploiting matrix multiplication and treating the head axis as an additional batch-like dimension.</p>\n<p>In the previous multi-head implementation, each head was composed of three matrices created inside the single head. The idea is to still create the three projections, but originate them from one bigger matrix and then act as if they were separated by head:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> MultiHeadParallel</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, config):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        assert</span><span> config.n_embd </span><span>%</span><span> config.n_head </span><span>==</span><span> 0</span></span>\n<span class=\"line\"><span>        # key, query, value projections for all heads, but in a batch</span></span>\n<span class=\"line\"><span>        self</span><span>.c_attn </span><span>=</span><span> nn.Linear(config.n_embd, </span><span>3</span><span> *</span><span> config.n_embd)</span></span>\n<span class=\"line\"><span>        # output projection</span></span>\n<span class=\"line\"><span>        self</span><span>.c_proj </span><span>=</span><span> nn.Linear(config.n_embd, config.n_embd)</span></span>\n<span class=\"line\"><span>        # regularization</span></span>\n<span class=\"line\"><span>        self</span><span>.attn_dropout </span><span>=</span><span> nn.Dropout(config.attn_pdrop)</span></span>\n<span class=\"line\"><span>        self</span><span>.resid_dropout </span><span>=</span><span> nn.Dropout(config.resid_pdrop)</span></span>\n<span class=\"line\"><span>        # causal mask to ensure that attention is only applied to the left in the input sequence</span></span>\n<span class=\"line\"><span>        self</span><span>.register_buffer(</span><span>\"bias\"</span><span>, torch.tril(torch.ones(config.block_size, config.block_size))</span></span>\n<span class=\"line\"><span>                                     .view(</span><span>1</span><span>, </span><span>1</span><span>, config.block_size, config.block_size))</span></span>\n<span class=\"line\"><span>        self</span><span>.n_head </span><span>=</span><span> config.n_head</span></span>\n<span class=\"line\"><span>        self</span><span>.n_embd </span><span>=</span><span> config.n_embd</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        B, T, C </span><span>=</span><span> x.size() </span><span># batch size, sequence length, embedding dimensionality (n_embd)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # calculate query, key, values for all heads in batch and move head forward to be the batch dim</span></span>\n<span class=\"line\"><span>        q, k ,v  </span><span>=</span><span> self</span><span>.c_attn(x).split(</span><span>self</span><span>.n_embd, </span><span>dim</span><span>=</span><span>2</span><span>)</span></span>\n<span class=\"line\"><span>        k </span><span>=</span><span> k.view(B, T, </span><span>self</span><span>.n_head, C </span><span>//</span><span> self</span><span>.n_head).transpose(</span><span>1</span><span>, </span><span>2</span><span>) </span><span># (B, nh, T, hs)</span></span>\n<span class=\"line\"><span>        q </span><span>=</span><span> q.view(B, T, </span><span>self</span><span>.n_head, C </span><span>//</span><span> self</span><span>.n_head).transpose(</span><span>1</span><span>, </span><span>2</span><span>) </span><span># (B, nh, T, hs)</span></span>\n<span class=\"line\"><span>        v </span><span>=</span><span> v.view(B, T, </span><span>self</span><span>.n_head, C </span><span>//</span><span> self</span><span>.n_head).transpose(</span><span>1</span><span>, </span><span>2</span><span>) </span><span># (B, nh, T, hs)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -&gt; (B, nh, T, T)</span></span>\n<span class=\"line\"><span>        att </span><span>=</span><span> (q </span><span>@</span><span> k.transpose(</span><span>-</span><span>2</span><span>, </span><span>-</span><span>1</span><span>)) </span><span>*</span><span> (</span><span>1.0</span><span> /</span><span> math.sqrt(k.size(</span><span>-</span><span>1</span><span>)))</span></span>\n<span class=\"line\"><span>        att </span><span>=</span><span> att.masked_fill(</span><span>self</span><span>.bias[:,:,:T,:T] </span><span>==</span><span> 0</span><span>, </span><span>float</span><span>(</span><span>'-inf'</span><span>))</span></span>\n<span class=\"line\"><span>        att </span><span>=</span><span> F.softmax(att, </span><span>dim</span><span>=-</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        att </span><span>=</span><span> self</span><span>.attn_dropout(att)</span></span>\n<span class=\"line\"><span>        y </span><span>=</span><span> att </span><span>@</span><span> v </span><span># (B, nh, T, T) x (B, nh, T, hs) -&gt; (B, nh, T, hs)</span></span>\n<span class=\"line\"><span>        y </span><span>=</span><span> y.transpose(</span><span>1</span><span>, </span><span>2</span><span>).contiguous().view(B, T, C) </span><span># re-assemble all head outputs side by side</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # output projection</span></span>\n<span class=\"line\"><span>        y </span><span>=</span><span> self</span><span>.resid_dropout(</span><span>self</span><span>.c_proj(y))</span></span>\n<span class=\"line\"><span>        return</span><span> y</span></span></code></pre>\n<p>This implementation is adapted from Karpathy's <a href=\"https://github.com/karpathy/minGPT\" rel=\"noopener noreferrer\">minGPT repository</a>.\nThe interesting part, in my opinion, is that we are adding a head dimension and using it to compute multiple heads in parallel. At the end, we put everything back together to obtain the <code>(B, T, C)</code> matrix.</p>\n<h2>Feed-Forward Network</h2>\n<p>After attention, each token has received information from the previous context.</p>\n<p>Then we let each token process its own representation independently with a feed-forward network:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> FeedForward</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, n_embd):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.net </span><span>=</span><span> nn.Sequential(</span></span>\n<span class=\"line\"><span>            nn.Linear(n_embd, </span><span>4</span><span> *</span><span> n_embd),</span></span>\n<span class=\"line\"><span>            nn.ReLU(),</span></span>\n<span class=\"line\"><span>            nn.Linear(</span><span>4</span><span> *</span><span> n_embd, n_embd),</span></span>\n<span class=\"line\"><span>            nn.Dropout(dropout),</span></span>\n<span class=\"line\"><span>        )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        return</span><span> self</span><span>.net(x)</span></span></code></pre>\n<p>Attention is the communication step. The feed-forward network is the computation step in which we let the channels of each token's internal representation mix together.</p>\n<h2>Transformer Block</h2>\n<p>A transformer block combines:</p>\n<ul>\n<li>communication: multi-head self-attention</li>\n<li>computation: feed-forward network</li>\n<li>residual connections</li>\n<li>layer normalization</li>\n</ul>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Block</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, n_embd, n_head):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        head_size </span><span>=</span><span> n_embd </span><span>//</span><span> n_head</span></span>\n<span class=\"line\"><span>        self</span><span>.sa </span><span>=</span><span> MultiHeadAttention(n_head, head_size)</span></span>\n<span class=\"line\"><span>        self</span><span>.ffwd </span><span>=</span><span> FeedForward(n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.ln1 </span><span>=</span><span> nn.LayerNorm(n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.ln2 </span><span>=</span><span> nn.LayerNorm(n_embd)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> x </span><span>+</span><span> self</span><span>.sa(</span><span>self</span><span>.ln1(x))</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> x </span><span>+</span><span> self</span><span>.ffwd(</span><span>self</span><span>.ln2(x))</span></span>\n<span class=\"line\"><span>        return</span><span> x</span></span></code></pre>\n<p>The residual connections give gradients a direct path through the network. This is important because the model becomes deep once we stack multiple transformer blocks.</p>\n<p>The layer normalization standardizes each token along the feature dimension. In this implementation it is applied before attention and before the feed-forward network.</p>\n<p>This is called a pre-norm transformer block.</p>\n<h2>Small GPT Model</h2>\n<p>The full model uses:</p>\n<ul>\n<li>token embeddings</li>\n<li>positional embeddings</li>\n<li>stacked transformer blocks</li>\n<li>final layer normalization</li>\n<li>a linear layer that predicts the next token</li>\n</ul>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> GPTLanguageModel</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.token_embedding_table </span><span>=</span><span> nn.Embedding(vocab_size, n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.position_embedding_table </span><span>=</span><span> nn.Embedding(block_size, n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.blocks </span><span>=</span><span> nn.Sequential(</span><span>*</span><span>[Block(n_embd, </span><span>n_head</span><span>=</span><span>n_head) </span><span>for</span><span> _ </span><span>in</span><span> range</span><span>(n_layer)])</span></span>\n<span class=\"line\"><span>        self</span><span>.ln_f </span><span>=</span><span> nn.LayerNorm(n_embd)</span></span>\n<span class=\"line\"><span>        self</span><span>.lm_head </span><span>=</span><span> nn.Linear(n_embd, vocab_size)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, idx, targets</span><span>=</span><span>None</span><span>):</span></span>\n<span class=\"line\"><span>        B, T </span><span>=</span><span> idx.shape</span></span>\n<span class=\"line\"><span>        tok_emb </span><span>=</span><span> self</span><span>.token_embedding_table(idx)</span></span>\n<span class=\"line\"><span>        pos_emb </span><span>=</span><span> self</span><span>.position_embedding_table(torch.arange(T, </span><span>device</span><span>=</span><span>idx.device))</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> tok_emb </span><span>+</span><span> pos_emb</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> self</span><span>.blocks(x)</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> self</span><span>.ln_f(x)</span></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> self</span><span>.lm_head(x)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        if</span><span> targets </span><span>is</span><span> None</span><span>:</span></span>\n<span class=\"line\"><span>            loss </span><span>=</span><span> None</span></span>\n<span class=\"line\"><span>        else</span><span>:</span></span>\n<span class=\"line\"><span>            B, T, C </span><span>=</span><span> logits.shape</span></span>\n<span class=\"line\"><span>            logits </span><span>=</span><span> logits.view(B </span><span>*</span><span> T, C)</span></span>\n<span class=\"line\"><span>            targets </span><span>=</span><span> targets.view(B </span><span>*</span><span> T)</span></span>\n<span class=\"line\"><span>            loss </span><span>=</span><span> F.cross_entropy(logits, targets)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        return</span><span> logits, loss</span></span></code></pre>\n<p>The token embedding tells the model what token it is looking at. The positional embedding tells the model where the token is in the context window.</p>\n<p>Without positional embeddings, the transformer has no direct notion of order.</p>\n<h2>Top-Down View</h2>\n<p>A compact way to see the full path is:</p>\n<ol>\n<li>We want to predict the next character.</li>\n<li>To do that, we represent characters as tokens.</li>\n<li>Since order matters, we add positional information.</li>\n<li>To predict well, each token needs information from the previous context.</li>\n<li>To obtain that information, we use causal self-attention.</li>\n<li>In self-attention, every token emits a query, a key, and a value.</li>\n<li>Query-key dot products decide how much information should be taken from each previous token.</li>\n<li>Values carry the information that is actually aggregated.</li>\n<li>Multiple heads allow different attention patterns to run in parallel.</li>\n<li>Feed-forward layers let each token process its own representation.</li>\n<li>Residual connections and layer normalization make the deep network trainable.</li>\n</ol>\n<h2>Self-Attention and Cross-Attention</h2>\n<p>This mechanism is called self-attention because queries, keys, and values all come from the same input <code>x</code>.</p>\n<p>In cross-attention, the queries usually come from one sequence, while keys and values come from another sequence.</p>\n<p>For example, in an encoder-decoder transformer:</p>\n<ul>\n<li>decoder states produce the queries</li>\n<li>encoder states produce the keys and values</li>\n</ul>\n<p>So cross-attention lets one sequence attend to information produced by another sequence.</p>\n<p>In a GPT-style decoder-only model, we only use causal self-attention.</p>\n<h2>Conclusion</h2>\n<p>The attention mechanism is a learned, data-dependent way to move information between tokens.</p>\n<p>The causal mask makes it usable for language modeling because each token can only look at the past. Scaling keeps the softmax numerically well-behaved. Multi-head attention lets the model learn several communication patterns at the same time.</p>\n<p>Once attention is combined with feed-forward layers, residual connections, dropout, and layer normalization, we obtain the basic transformer block used in a small GPT.</p>","date_published":"2026-06-22T00:00:00.000Z","tags":["Neural Networks","Deep Learning","Transformers","Attention","Python"]},{"id":"https://www.tommasovaccari.com/blog/neural-network-training-statistics-initialization-saturation-diagnostics","url":"https://www.tommasovaccari.com/blog/neural-network-training-statistics-initialization-saturation-diagnostics","title":"Neural Network Training: Statistics, Initialization and Diagnostics","summary":"A structured study of neural network training dynamics, covering statistical preliminaries, loss interpretation, saturation, gradient flow, initialization schemes, diagnostic tools, and stabilization mechanisms.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2>Introduction</h2>\n<p>This post comes from a cleaned-up version of the notes I wrote while studying neural network training. The goal is to turn those notes into something more readable, without losing the practical and exploratory style of the original work.</p>\n<p>In these notes I dive into a set of concepts that are fundamental for understanding how neural networks work and why they behave in a certain manner during training. These ideas are not only useful for building intuition, but also essential for debugging models, diagnosing training instabilities, and making optimization behave properly.</p>\n<p>The topics covered are:</p>\n<ul>\n<li>basic statistical concepts and useful probability distributions</li>\n<li>model outputs, softmax, and what to expect from the initial loss</li>\n<li>backpropagation</li>\n<li>common problems that arise during training</li>\n<li>how initialization affects training, how variance propagates across layers, and some signal-processing intuition behind these effects</li>\n<li>diagnostic tools and training KPIs</li>\n<li>architectural mechanisms that patch these problems</li>\n</ul>\n<blockquote>\n<p>The code and plots in this post are developed from a notebook, available in this GitHub repository: <a href=\"https://github.com/T-vaccari/Neural-Network-Statistic-and-diagnostic-tools\" rel=\"noopener noreferrer\">Neural-Network-Statistic-and-diagnostic-tools</a>.</p>\n</blockquote>\n<h2>Statistics &amp; Distributions</h2>\n<p>Firstly we have to introduce some basic concepts, starting from the definition of a <a href=\"https://en.wikipedia.org/wiki/Random_variable\" rel=\"noopener noreferrer\">random variable</a>.</p>\n<p>We define <code class=\"formula-inline\">X</code> as a random variable when <code class=\"formula-inline\">X</code> represents the possible outcomes of a random process. This allows us to model uncertainty mathematically and to reason about events such as the probability that <code class=\"formula-inline\">X</code> falls inside a certain range.</p>\n<p>Random variables fall into two main categories:</p>\n<ul>\n<li>Discrete random variables</li>\n<li>Continuous random variables</li>\n</ul>\n<p>The main difference lies in the values that <code class=\"formula-inline\">X</code> can assume. A discrete random variable can assume values from a finite or countable set, while a continuous random variable can assume infinitely many values inside an interval.</p>\n<p>Every distribution is characterized by functions that describe how probability is assigned. For continuous random variables, the most important one is the probability density function, or pdf.</p>\n<p>The pdf can be misleading at first, because it does not directly give the probability that <code class=\"formula-inline\">X</code> is equal to a specific value. Instead, it describes density. Probabilities are obtained by integrating the density over an interval:</p>\n<div class=\"formula\"><code>P(a \\le X \\le b) = \\int_a^b f_X(x)\\,dx</code></div>\n<p>This also means that the probability of a single exact value is zero:</p>\n<div class=\"formula\"><code>P(X = a) = \\int_a^a f_X(x)\\,dx = 0</code></div>\n<p>So the intuitive interpretation is that probability is the area under the pdf curve.</p>\n<p>The cumulative distribution function, or cdf, accumulates this area from <code class=\"formula-inline\">-\\infty</code> up to a value <code class=\"formula-inline\">x</code>:</p>\n<div class=\"formula\"><code>F_X(x) = P(X \\le x) = \\int_{-\\infty}^{x} f_X(t)\\,dt</code></div>\n<p>Usually in machine learning we are interested in continuous random variables, and one of the most important examples is the Gaussian distribution.</p>\n<p>For a Gaussian random variable <code class=\"formula-inline\">X \\sim \\mathcal{N}(\\mu, \\sigma^2)</code>, the probability density function is:</p>\n<div class=\"formula\"><code>f_X(x) = \\frac{1}{\\sigma \\sqrt{2\\pi}}\ne^{-\\frac{(x - \\mu)^2}{2\\sigma^2}}</code></div>\n<p>In this case, the probability that <code class=\"formula-inline\">X</code> falls between <code class=\"formula-inline\">a</code> and <code class=\"formula-inline\">b</code> is:</p>\n<div class=\"formula\"><code>P(a \\le X \\le b) =\n\\int_a^b\n\\frac{1}{\\sigma \\sqrt{2\\pi}}\ne^{-\\frac{(x - \\mu)^2}{2\\sigma^2}}\\,dx</code></div>\n<p>The parameters <code class=\"formula-inline\">\\mu</code> and <code class=\"formula-inline\">\\sigma^2</code> are respectively the mean and the variance of the distribution. The mean describes where the distribution is centered, while the variance describes how spread out the values are around the mean.</p>\n<p>Their theoretical definitions are:</p>\n<div class=\"formula\"><code>\\mu = \\mathbb{E}[X] = \\int_{-\\infty}^{+\\infty} x f_X(x)\\,dx</code></div>\n<div class=\"formula\"><code>\\sigma^2 = \\mathrm{Var}(X) = \\mathbb{E}[(X - \\mu)^2]\n= \\int_{-\\infty}^{+\\infty} (x - \\mu)^2 f_X(x)\\,dx</code></div>\n<p>When we work with data, we usually do not know the true distribution parameters. We only have samples, so we estimate the mean and variance from them.</p>\n<p>Given samples <code class=\"formula-inline\">x_1, x_2, \\dots, x_n</code>, the sample mean is:</p>\n<div class=\"formula\"><code>\\bar{x} = \\frac{1}{n}\\sum_{i=1}^{n} x_i</code></div>\n<p>The sample variance is:</p>\n<div class=\"formula\"><code>s^2 = \\frac{1}{n - 1}\\sum_{i=1}^{n}(x_i - \\bar{x})^2</code></div>\n<p>So basically the mean is derived from averaging the values, and the variance is derived by seeing how each value differs from the mean. We square these differences, average them, and obtain a measure of how spread out the samples are around the mean. If you have noticed that in the second case we do not average by <code class=\"formula-inline\">n</code> but by <code class=\"formula-inline\">n - 1</code>, I suggest you read this <a href=\"https://mathcenter.oxford.emory.edu/site/math117/besselCorrection/\" rel=\"noopener noreferrer\">explanation</a>.</p>\n<p>Now that we have defined these basic concepts, we can use Python and PyTorch to build some intuition through examples. We can use these tools to generate samples from a standard Gaussian distribution:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>12</span><span>, </span><span>8</span><span>))</span></span>\n<span class=\"line\"><span>bins </span><span>=</span><span> torch.linspace(</span><span>-</span><span>4</span><span>, </span><span>4</span><span>, </span><span>51</span><span>).numpy()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> idx, i </span><span>in</span><span> enumerate</span><span>((</span><span>100</span><span>, </span><span>1000</span><span>, </span><span>10000</span><span>, </span><span>100000</span><span>), </span><span>start</span><span>=</span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    x </span><span>=</span><span> torch.randn(i)</span></span>\n<span class=\"line\"><span>    mean </span><span>=</span><span> x.mean()</span></span>\n<span class=\"line\"><span>    variance </span><span>=</span><span> x.var(</span><span>unbiased</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    plt.subplot(</span><span>2</span><span>, </span><span>2</span><span>, idx)</span></span>\n<span class=\"line\"><span>    plt.hist(x.numpy(), </span><span>bins</span><span>=</span><span>bins, </span><span>density</span><span>=</span><span>True</span><span>, </span><span>alpha</span><span>=</span><span>0.7</span><span>, </span><span>color</span><span>=</span><span>\"g\"</span><span>)</span></span>\n<span class=\"line\"><span>    plt.title(</span><span>f</span><span>\"n = </span><span>{</span><span>i</span><span>}\\n</span><span>mean = </span><span>{</span><span>mean</span><span>:.3f</span><span>}</span><span>, var = </span><span>{</span><span>variance</span><span>:.3f</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span>\n<span class=\"line\"><span>plt.show()</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/sample-size-histograms-e2fbea40.webp\" alt=\"Sample size histograms\" /></p>\n<p>As we can see, with a small number of samples, the mean and variance can deviate significantly from their true values (0 and 1 for a standard normal distribution). As we increase the number of samples, the estimates become more accurate, and the histogram approaches the shape of the true Gaussian distribution. If you are interested to delve deeper, this is related to the <a href=\"https://en.wikipedia.org/wiki/Law_of_large_numbers\" rel=\"noopener noreferrer\">law of large numbers</a> and the <a href=\"https://en.wikipedia.org/wiki/Central_limit_theorem\" rel=\"noopener noreferrer\">central limit theorem</a>.</p>\n<p>Now we can see intuitively how mean and variance modify the shape of the bell curve, that is, the plot of the Gaussian distribution over a histogram. The mean shifts the center of the bell curve, while the variance controls how wide or narrow it is. A higher variance results in a wider bell curve, while a lower variance results in a narrower bell curve.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>12</span><span>, </span><span>10</span><span>))</span></span>\n<span class=\"line\"><span>bins </span><span>=</span><span> torch.linspace(</span><span>-</span><span>8</span><span>, </span><span>8</span><span>, </span><span>80</span><span>).numpy()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.subplot(</span><span>2</span><span>, </span><span>1</span><span>, </span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>for</span><span> mu </span><span>in</span><span> (</span><span>-</span><span>3</span><span>, </span><span>0</span><span>, </span><span>3</span><span>):</span></span>\n<span class=\"line\"><span>    x </span><span>=</span><span> torch.randn(</span><span>10000</span><span>) </span><span>+</span><span> mu</span></span>\n<span class=\"line\"><span>    plt.hist(x.numpy(), </span><span>bins</span><span>=</span><span>bins, </span><span>density</span><span>=</span><span>True</span><span>, </span><span>alpha</span><span>=</span><span>0.5</span><span>, </span><span>label</span><span>=</span><span>f</span><span>\"mu = </span><span>{</span><span>mu</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.title(</span><span>\"Fixed variance, changing mean\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.legend()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.subplot(</span><span>2</span><span>, </span><span>1</span><span>, </span><span>2</span><span>)</span></span>\n<span class=\"line\"><span>for</span><span> sigma </span><span>in</span><span> (</span><span>0.5</span><span>, </span><span>1</span><span>, </span><span>2</span><span>):</span></span>\n<span class=\"line\"><span>    x </span><span>=</span><span> torch.randn(</span><span>10000</span><span>) </span><span>*</span><span> sigma</span></span>\n<span class=\"line\"><span>    plt.hist(x.numpy(), </span><span>bins</span><span>=</span><span>bins, </span><span>density</span><span>=</span><span>True</span><span>, </span><span>alpha</span><span>=</span><span>0.5</span><span>, </span><span>label</span><span>=</span><span>f</span><span>\"sigma = </span><span>{</span><span>sigma</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.title(</span><span>\"Fixed mean, changing variance\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.legend()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span>\n<span class=\"line\"><span>plt.show()</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/mean-variance-histograms-1434e153.webp\" alt=\"Mean and variance histograms\" /></p>\n<p>Now that we have built some intuition about these concepts, we can look at what happens when we apply a nonlinear transformation to samples drawn from a random variable. In our context, we want to see how an initially Gaussian distribution changes when we apply the tanh activation function.</p>\n<p>The tanh activation function is defined as follows:</p>\n<div class=\"formula\"><code>\\tanh(x) = \\frac{e^x - e^{-x}}{e^x + e^{-x}}</code></div>\n<p>Its output is bounded between <code class=\"formula-inline\">-1</code> and <code class=\"formula-inline\">1</code>:</p>\n<div class=\"formula\"><code>\\tanh(x) \\in (-1, 1)</code></div>\n<p><img src=\"https://www.tommasovaccari.com/static/tanh-function-6350b865.webp\" alt=\"Tanh activation function\" /></p>\n<p>If we apply tanh to every sample and then plot the result, we can see that the output distribution is no longer Gaussian. Its mean and variance are changed by the nonlinearity:</p>\n<p><img src=\"https://www.tommasovaccari.com/static/tanh-distribution-transformations-6d53dcfe.webp\" alt=\"Tanh distribution transformations\" /></p>\n<p>The most important thing to grasp is that the output distribution depends strongly on the input variance. When the variance is large, many input values fall far from the mean. Since tanh is almost flat for large positive or negative values, those samples are pushed close to <code class=\"formula-inline\">-1</code> or <code class=\"formula-inline\">1</code>. This is called tanh saturation, and we will come back to it later.</p>\n<p>On the other hand, when the variance is small, most values stay close to <code class=\"formula-inline\">0</code>. Around zero, tanh is almost linear, so the transformed values also remain close to zero. You can see it respectively in row 2 and row 3 of the image.</p>\n<h2>Neural Network Model</h2>\n<p>To follow along during the explanation we are going to use a simple neural network from the previos post. The context is the same, we have a datasets composed of names and we are trying to train a bigram model to learn how to generate italian names.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># MLP </span></span>\n<span class=\"line\"><span>n_embd </span><span>=</span><span> 10</span><span> # the dimensionality of the character embedding vectors</span></span>\n<span class=\"line\"><span>n_hidden </span><span>=</span><span> 200</span><span> # the number of neurons in the hidden layer of the MLP</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>g </span><span>=</span><span> torch.Generator().manual_seed(</span><span>2147483647</span><span>) </span><span># for reproducibility</span></span>\n<span class=\"line\"><span>C  </span><span>=</span><span> torch.randn((vocab_size, n_embd),            </span><span>generator</span><span>=</span><span>g)</span></span>\n<span class=\"line\"><span>W1 </span><span>=</span><span> torch.randn((n_embd </span><span>*</span><span> block_size, n_hidden), </span><span>generator</span><span>=</span><span>g)</span><span>#* ((5/3)/(n_embd*block_size)**0.5)</span></span>\n<span class=\"line\"><span>b1 </span><span>=</span><span> torch.randn(n_hidden,                        </span><span>generator</span><span>=</span><span>g)</span><span>#*0.01</span></span>\n<span class=\"line\"><span>W2 </span><span>=</span><span> torch.randn((n_hidden, vocab_size),          </span><span>generator</span><span>=</span><span>g)</span><span>*</span><span>0.01</span></span>\n<span class=\"line\"><span>b2 </span><span>=</span><span> torch.randn(vocab_size,                      </span><span>generator</span><span>=</span><span>g)</span><span>*</span><span>0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>parameters </span><span>=</span><span> [C, W1, b1, W2, b2]</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>sum</span><span>(</span><span>list</span><span>((p.nelement() </span><span>for</span><span> p </span><span>in</span><span> parameters)))) </span><span># number of parameters in total</span></span>\n<span class=\"line\"><span>for</span><span> p </span><span>in</span><span> parameters:</span></span>\n<span class=\"line\"><span>    p.requires_grad </span><span>=</span><span> True</span></span>\n<span class=\"line\"></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># same optimization as last time</span></span>\n<span class=\"line\"><span>max_steps </span><span>=</span><span> 200000</span></span>\n<span class=\"line\"><span>batch_size </span><span>=</span><span> 32</span></span>\n<span class=\"line\"><span>lossi </span><span>=</span><span> []</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> i </span><span>in</span><span> range</span><span>(max_steps):</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  # minibatch construct</span></span>\n<span class=\"line\"><span>  ix </span><span>=</span><span> torch.randint(</span><span>0</span><span>, Xtr.shape[</span><span>0</span><span>], (batch_size,), </span><span>generator</span><span>=</span><span>g)</span></span>\n<span class=\"line\"><span>  Xb, Yb </span><span>=</span><span> Xtr[ix], Ytr[ix] </span><span># batch X,Y</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  # forward pass</span></span>\n<span class=\"line\"><span>  emb </span><span>=</span><span> C[Xb] </span><span># embed the characters into vectors</span></span>\n<span class=\"line\"><span>  embcat </span><span>=</span><span> emb.view(emb.shape[</span><span>0</span><span>], </span><span>-</span><span>1</span><span>) </span><span># concatenate the vectors</span></span>\n<span class=\"line\"><span>  hpreact </span><span>=</span><span> embcat </span><span>@</span><span> W1 </span><span>#+ b1 # hidden layer pre-activation</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>  h </span><span>=</span><span> torch.tanh(hpreact) </span><span># hidden layer</span></span>\n<span class=\"line\"><span>  logits </span><span>=</span><span> h </span><span>@</span><span> W2 </span><span>+</span><span> b2 </span><span># output layer</span></span>\n<span class=\"line\"><span>  loss </span><span>=</span><span> F.cross_entropy(logits, Yb) </span><span># loss function</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  # backward pass</span></span>\n<span class=\"line\"><span>  for</span><span> p </span><span>in</span><span> parameters:</span></span>\n<span class=\"line\"><span>    p.grad </span><span>=</span><span> None</span></span>\n<span class=\"line\"><span>  loss.backward()</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  # update</span></span>\n<span class=\"line\"><span>  lr </span><span>=</span><span> 0.1</span><span> if</span><span> i </span><span>&lt;</span><span> 100000</span><span> else</span><span> 0.01</span><span> # step learning rate decay</span></span>\n<span class=\"line\"><span>  for</span><span> p </span><span>in</span><span> parameters:</span></span>\n<span class=\"line\"><span>    if</span><span> p.grad </span><span>is</span><span> not</span><span> None</span><span>:</span></span>\n<span class=\"line\"><span>      p.data </span><span>+=</span><span> -</span><span>lr </span><span>*</span><span> p.grad</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  # track stats</span></span>\n<span class=\"line\"><span>  if</span><span> i </span><span>%</span><span> 10000</span><span> ==</span><span> 0</span><span>: </span><span># print every once in a while</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>'</span><span>{</span><span>i</span><span>:7d</span><span>}</span><span>/</span><span>{</span><span>max_steps</span><span>:7d</span><span>}</span><span>: </span><span>{</span><span>loss.item()</span><span>:.4f</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>  lossi.append(loss.log10().item())</span></span>\n<span class=\"line\"></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/forward-pass-network-753d96ab.webp\" alt=\"Forward pass network diagram\" /></p>\n<p>As you can see the model and the forward pass are pretty simple, we have the following:</p>\n<ul>\n<li><code>ix</code>: random indices used to sample a minibatch from the training set. Shape: <code>(batch_size,)</code>.</li>\n<li><code>Xb</code>: input batch, obtained with <code>Xtr[ix]</code>. Each row contains the context characters used to predict the next one. Shape: <code>(batch_size, block_size)</code>.</li>\n<li><code>Yb</code>: target batch, obtained with <code>Ytr[ix]</code>. Each element is the correct next character index. Shape: <code>(batch_size,)</code>.</li>\n<li><code>emb = C[Xb]</code>: embedding lookup. Each character index in <code>Xb</code> is replaced by its learned vector from <code>C</code>. Shape: <code>(batch_size, block_size, n_embd)</code>.</li>\n<li><code>embcat = emb.view(emb.shape[0], -1)</code>: concatenation of the context embeddings into a single vector per example. Shape: <code>(batch_size, block_size * n_embd)</code>.</li>\n<li><code>hpreact = embcat @ W1</code>: hidden layer pre-activation, before applying the nonlinearity. Shape: <code>(batch_size, n_hidden)</code>.</li>\n<li><code>h = torch.tanh(hpreact)</code>: hidden layer activation. Shape: <code>(batch_size, n_hidden)</code>.</li>\n<li><code>logits = h @ W2 + b2</code>: raw output scores, one score for each possible next character. Shape: <code>(batch_size, vocab_size)</code>.</li>\n<li><code>loss = F.cross_entropy(logits, Yb)</code>: compares the logits with the correct targets and returns a scalar loss. Shape: <code>()</code>.</li>\n</ul>\n<h2>Cross-Entropy Loss and Output Scale</h2>\n<p>At the end of the network we obtain <code>logits</code>, one raw score for each possible next character. These are not probabilities yet. They can be any real number, positive or negative.</p>\n<p>In PyTorch, <code>F.cross_entropy(logits, Yb)</code> expects these raw logits directly.</p>\n<p>Under the hood, PyTorch's cross-entropy combines two operations:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>log_probs </span><span>=</span><span> F.log_softmax(logits, </span><span>dim</span><span>=</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>loss </span><span>=</span><span> F.nll_loss(log_probs, Yb)</span></span></code></pre>\n<p>So when we write:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>loss </span><span>=</span><span> F.cross_entropy(logits, Yb)</span></span></code></pre>\n<p>we are already doing the stable version of softmax + negative log-likelihood.</p>\n<p>For a single example, let:</p>\n<div class=\"formula\"><code>z \\in \\mathbb{R}^{V}</code></div>\n<p>be the logits, where <code class=\"formula-inline\">V</code> is the vocabulary size. Softmax turns these raw scores into probabilities:</p>\n<div class=\"formula\"><code>p_i = \\frac{e^{z_i}}{\\sum_{j=1}^{V} e^{z_j}}</code></div>\n<p>This does two things: it makes every value positive, and it normalizes all values so that they sum to one:</p>\n<div class=\"formula\"><code>\\sum_{i=1}^{V} p_i = 1</code></div>\n<p>So after softmax we can interpret <code class=\"formula-inline\">p_i</code> as the probability assigned to token <code class=\"formula-inline\">i</code>.</p>\n<p>Log-softmax does the same transformation, but directly in log space:</p>\n<div class=\"formula\"><code>\\log p_i = z_i - \\log\\left(\\sum_{j=1}^{V} e^{z_j}\\right)</code></div>\n<p>This is the numerically stable quantity used by the loss. If the correct target class is <code class=\"formula-inline\">y</code>, the negative log-likelihood is:</p>\n<div class=\"formula\"><code>\\mathcal{L} = -\\log p_y</code></div>\n<p>So the loss is small when the model assigns high probability to the correct token, and large when it assigns low probability to it.</p>\n<p>Softmax is still useful as intuition. If the logits are close to each other, the output distribution is close to uniform. If one logit is much larger than the others, softmax puts most of the mass on that class.</p>\n<p>So the scale of the final logits matters. Small logits usually mean a softer, less confident output distribution. Very large logits usually mean a very sharp, overconfident distribution.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>g_softmax </span><span>=</span><span> torch.Generator().manual_seed(</span><span>2147483647</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> scale </span><span>in</span><span> (</span><span>0.1</span><span>, </span><span>1.0</span><span>, </span><span>10.0</span><span>):</span></span>\n<span class=\"line\"><span>    logits </span><span>=</span><span> torch.randn(</span><span>4</span><span>, </span><span>generator</span><span>=</span><span>g_softmax) </span><span>*</span><span> scale</span></span>\n<span class=\"line\"><span>    probs </span><span>=</span><span> torch.softmax(logits, </span><span>dim</span><span>=</span><span>0</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>\"scale = </span><span>{</span><span>scale</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>\"logits:\"</span><span>, logits.round(</span><span>decimals</span><span>=</span><span>3</span><span>))</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>\"probs :\"</span><span>, probs.round(</span><span>decimals</span><span>=</span><span>3</span><span>))</span></span>\n<span class=\"line\"><span>    print</span><span>()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>scale = 0.1</span></span>\n<span class=\"line\"><span>logits: tensor([-0.0980, -0.1660, -0.0060, -0.0340])</span></span>\n<span class=\"line\"><span>probs : tensor([0.2440, 0.2280, 0.2680, 0.2600])</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>scale = 1.0</span></span>\n<span class=\"line\"><span>logits: tensor([ 2.0990,  0.8960,  0.3380, -0.2090])</span></span>\n<span class=\"line\"><span>probs : tensor([0.6360, 0.1910, 0.1090, 0.0630])</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>scale = 10.0</span></span>\n<span class=\"line\"><span>logits: tensor([ -9.3910, -11.8100,  -6.2840,  -9.8260])</span></span>\n<span class=\"line\"><span>probs : tensor([0.0420, 0.0040, 0.9280, 0.0270])</span></span></code></pre>\n<p>This is why the magnitude of the final layer matters at initialization. At the beginning of training, the model has not learned anything yet, so it should not be very confident about any particular token. Its predictions should be close to uniform, and the loss should start near the baseline <code>log(vocab_size)</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>expected_initial_loss </span><span>=</span><span> torch.tensor(vocab_size).float().log()</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"vocab_size:\"</span><span>, vocab_size)</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"expected initial loss:\"</span><span>, expected_initial_loss.item())</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>vocab_size: 27</span></span>\n<span class=\"line\"><span>expected initial loss: 3.295836925506592</span></span></code></pre>\n<p>If the actual initial loss is much higher than this value, something is wrong with the initialization. The model is not just random: it is confidently random. It assigns too much probability to wrong classes, and cross-entropy punishes that heavily.</p>\n<p>This is exactly what creates the initial hockey-stick shape in the training loss curve:</p>\n<p><img src=\"https://www.tommasovaccari.com/static/lossi-25b58328.webp\" alt=\"Previous training loss curve\" /></p>\n<p>The loss starts too high, then quickly drops once the model learns to reduce its overconfident random predictions. But this early drop is not meaningful learning: it is mostly the model correcting a bad output scale.</p>\n<p>A simple fix is to initialize the last layer with smaller weights:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>W2 </span><span>=</span><span> torch.randn((n_hidden, vocab_size), </span><span>generator</span><span>=</span><span>g) </span><span>*</span><span> 0.01</span></span>\n<span class=\"line\"><span>b2 </span><span>=</span><span> torch.randn(vocab_size, </span><span>generator</span><span>=</span><span>g) </span><span>*</span><span> 0</span></span></code></pre>\n<p>We can see the effect directly by comparing a large output-layer scale with a smaller one:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>g_compare </span><span>=</span><span> torch.Generator().manual_seed(</span><span>2147483647</span><span>)</span></span>\n<span class=\"line\"><span>batch_size </span><span>=</span><span> 32</span></span>\n<span class=\"line\"><span>ix </span><span>=</span><span> torch.randint(</span><span>0</span><span>, Xtr.shape[</span><span>0</span><span>], (batch_size,), </span><span>generator</span><span>=</span><span>g_compare)</span></span>\n<span class=\"line\"><span>Xb, Yb </span><span>=</span><span> Xtr[ix], Ytr[ix]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>C_test </span><span>=</span><span> torch.randn((vocab_size, n_embd), </span><span>generator</span><span>=</span><span>g_compare)</span></span>\n<span class=\"line\"><span>W1_test </span><span>=</span><span> torch.randn((n_embd </span><span>*</span><span> block_size, n_hidden), </span><span>generator</span><span>=</span><span>g_compare) </span><span>*</span><span> ((</span><span>5</span><span>/</span><span>3</span><span>) </span><span>/</span><span> (n_embd </span><span>*</span><span> block_size)</span><span>**</span><span>0.5</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>emb </span><span>=</span><span> C_test[Xb]</span></span>\n<span class=\"line\"><span>embcat </span><span>=</span><span> emb.view(emb.shape[</span><span>0</span><span>], </span><span>-</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>hpreact </span><span>=</span><span> embcat </span><span>@</span><span> W1_test</span></span>\n<span class=\"line\"><span>h </span><span>=</span><span> torch.tanh(hpreact)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> scale </span><span>in</span><span> (</span><span>1.0</span><span>, </span><span>0.01</span><span>):</span></span>\n<span class=\"line\"><span>    W2_test </span><span>=</span><span> torch.randn((n_hidden, vocab_size), </span><span>generator</span><span>=</span><span>g_compare) </span><span>*</span><span> scale</span></span>\n<span class=\"line\"><span>    b2_test </span><span>=</span><span> torch.zeros(vocab_size)</span></span>\n<span class=\"line\"><span>    logits </span><span>=</span><span> h </span><span>@</span><span> W2_test </span><span>+</span><span> b2_test</span></span>\n<span class=\"line\"><span>    loss </span><span>=</span><span> F.cross_entropy(logits, Yb)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>\"W2 scale = </span><span>{</span><span>scale</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>\"logits std:\"</span><span>, logits.std().item())</span></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>\"loss:\"</span><span>, loss.item())</span></span>\n<span class=\"line\"><span>    print</span><span>()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>W2 scale = 1.0</span></span>\n<span class=\"line\"><span>logits std: 10.497655868530273</span></span>\n<span class=\"line\"><span>loss: 25.23309326171875</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>W2 scale = 0.01</span></span>\n<span class=\"line\"><span>logits std: 0.11374638229608536</span></span>\n<span class=\"line\"><span>loss: 3.3066704273223877</span></span></code></pre>\n<p>This keeps the initial logits close to zero. When logits are close to zero, softmax would produce probabilities close to uniform, and the initial loss starts near the theoretical baseline.</p>\n<p>Now we can train again with this initialization and plot the loss curve. To make the trend easier to read, we average the loss every 1000 iterations:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Now we can plot the loss curve to see how it evolves during training</span></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>10</span><span>, </span><span>5</span><span>))</span></span>\n<span class=\"line\"><span>lossi_avg </span><span>=</span><span> torch.tensor(lossi).view(</span><span>-</span><span>1</span><span>, </span><span>1000</span><span>).mean(</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>plt.plot(lossi_avg, </span><span>label</span><span>=</span><span>\"Training Loss (log10)\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.axvline(</span><span>100</span><span>, </span><span>color</span><span>=</span><span>\"r\"</span><span>, </span><span>linestyle</span><span>=</span><span>\"--\"</span><span>, </span><span>label</span><span>=</span><span>\"learning-rate decay\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.xlabel(</span><span>\"1000-iteration blocks\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.ylabel(</span><span>\"log10(loss)\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.legend()</span></span>\n<span class=\"line\"><span>plt.show()</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/training-loss-moving-average-a9ec4333.webp\" alt=\"Training loss averaged every 1000 iterations\" /></p>\n<p>Each point is the average of 1000 consecutive training iterations. The vertical line is at block <code>100</code>, which corresponds to iteration <code>100000</code>, where the learning rate changes from <code>0.1</code> to <code>0.01</code>. With the smaller last-layer initialization, the curve no longer starts with the same artificial hockey-stick drop.</p>\n<h2>Manual BackPropagation through the net</h2>\n<p>Now we want to understand what happens under the hood when we call:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>loss.backward()</span></span></code></pre>\n<p>The loss is just a function of all the parameters of the network:</p>\n<div class=\"formula\"><code>\\mathcal{L} = f(C, W_1, b_1, W_2, b_2)</code></div>\n<p>Training means changing these parameters in the direction that reduces the loss. For each parameter <code class=\"formula-inline\">\\theta</code>, PyTorch computes:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial \\theta}</code></div>\n<p>This tells us how much the loss changes when that parameter changes a little.</p>\n<p>Then the update step is:</p>\n<div class=\"formula\"><code>\\theta \\leftarrow \\theta - \\eta \\frac{\\partial \\mathcal{L}}{\\partial \\theta}</code></div>\n<p>where <code class=\"formula-inline\">\\eta</code> is the learning rate.</p>\n<p>In code, this is the part:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># backward pass</span></span>\n<span class=\"line\"><span>for</span><span> p </span><span>in</span><span> parameters:</span></span>\n<span class=\"line\"><span>    p.grad </span><span>=</span><span> None</span></span>\n<span class=\"line\"><span>loss.backward()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># update</span></span>\n<span class=\"line\"><span>lr </span><span>=</span><span> 0.1</span><span> if</span><span> i </span><span>&lt;</span><span> 100000</span><span> else</span><span> 0.01</span><span> # step learning rate decay</span></span>\n<span class=\"line\"><span>for</span><span> p </span><span>in</span><span> parameters:</span></span>\n<span class=\"line\"><span>    if</span><span> p.grad </span><span>is</span><span> not</span><span> None</span><span>:</span></span>\n<span class=\"line\"><span>        p.data </span><span>+=</span><span> -</span><span>lr </span><span>*</span><span> p.grad</span></span></code></pre>\n<p>First we reset the old gradients, then <code>.backward()</code> computes the new gradients, and finally we move every parameter in the opposite direction of its gradient.</p>\n<p>The key mathematical tool is the chain rule. If a function is built by composing smaller functions:</p>\n<div class=\"formula\"><code>y = f(g(x))</code></div>\n<p>then:</p>\n<div class=\"formula\"><code>\\frac{dy}{dx}\n=\n\\frac{dy}{dg}\n\\frac{dg}{dx}</code></div>\n<p>Backpropagation is just repeated chain rule applied from the loss backward through the network.</p>\n<p>To make the mechanism clear, we do not start from the full neural network. We start from a single simplified block:</p>\n<div class=\"formula\"><code>z = wx + b</code></div>\n<div class=\"formula\"><code>y = \\tanh(z)</code></div>\n<div class=\"formula\"><code>\\mathcal{L} = \\ell(y)</code></div>\n<p>So the full computation is a composition of functions:</p>\n<div class=\"formula\"><code>x\n\\xrightarrow{z = wx + b}\nz\n\\xrightarrow{y = \\tanh(z)}\ny\n\\xrightarrow{\\mathcal{L} = \\ell(y)}\n\\mathcal{L}</code></div>\n<p>During the forward pass we go from left to right. During backpropagation we go from right to left.</p>\n<p>The first gradient comes from the loss itself:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial y}</code></div>\n<p>Since:</p>\n<div class=\"formula\"><code>\\mathcal{L} = \\ell(y)</code></div>\n<p>we have:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial y}\n=\n\\ell'(y)</code></div>\n<p>This depends on the specific loss function. In the previous section we used cross-entropy. For softmax + cross-entropy, the gradient with respect to the logits has the clean form:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial z_i}\n=\np_i - \\mathbb{1}_{i=y}</code></div>\n<p>Here, in the simplified example, we just call the first gradient:</p>\n<div class=\"formula\"><code>g_y = \\frac{\\partial \\mathcal{L}}{\\partial y}</code></div>\n<p>Now we move one step backward. Since:</p>\n<div class=\"formula\"><code>y = \\tanh(z)</code></div>\n<p>we want to know how the loss changes when <code class=\"formula-inline\">z</code> changes. This is a two-function composition:</p>\n<div class=\"formula\"><code>z \\rightarrow y \\rightarrow \\mathcal{L}</code></div>\n<p>So the chain rule gives:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial z}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial y}\n\\frac{\\partial y}{\\partial z}</code></div>\n<p>The local derivative of tanh is:</p>\n<div class=\"formula\"><code>\\frac{\\partial y}{\\partial z}\n=\n1 - \\tanh^2(z)</code></div>\n<p>Since <code class=\"formula-inline\">y = \\tanh(z)</code>, this is also:</p>\n<div class=\"formula\"><code>\\frac{\\partial y}{\\partial z}\n=\n1 - y^2</code></div>\n<p>Therefore:</p>\n<div class=\"formula\"><code>g_z\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial y}\n(1 - y^2)\n=\n\\ell'(y)(1 - y^2)</code></div>\n<p>This quantity is the gradient that arrives at the linear part.</p>\n<p>Now we move one more step backward. The linear part is:</p>\n<div class=\"formula\"><code>z = wx + b</code></div>\n<p>There are three things we may care about: <code class=\"formula-inline\">w</code>, <code class=\"formula-inline\">b</code>, and <code class=\"formula-inline\">x</code>.</p>\n<p>First, for the weight <code class=\"formula-inline\">w</code>:</p>\n<div class=\"formula\"><code>w \\rightarrow z \\rightarrow \\mathcal{L}</code></div>\n<p>so:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial w}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}\n\\frac{\\partial z}{\\partial w}</code></div>\n<p>Since:</p>\n<div class=\"formula\"><code>\\frac{\\partial z}{\\partial w} = x</code></div>\n<p>we get:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial w}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}x</code></div>\n<p>For the bias <code class=\"formula-inline\">b</code>:</p>\n<div class=\"formula\"><code>b \\rightarrow z \\rightarrow \\mathcal{L}</code></div>\n<p>so:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial b}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}\n\\frac{\\partial z}{\\partial b}</code></div>\n<p>Since:</p>\n<div class=\"formula\"><code>\\frac{\\partial z}{\\partial b} = 1</code></div>\n<p>we get:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial b}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}</code></div>\n<p>For the input <code class=\"formula-inline\">x</code>:</p>\n<div class=\"formula\"><code>x \\rightarrow z \\rightarrow \\mathcal{L}</code></div>\n<p>so:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial x}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}\n\\frac{\\partial z}{\\partial x}</code></div>\n<p>Since:</p>\n<div class=\"formula\"><code>\\frac{\\partial z}{\\partial x} = w</code></div>\n<p>we get:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial x}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial z}w</code></div>\n<p>So the full backward pass for this small block is:</p>\n<div class=\"formula\"><code>g_y = \\frac{\\partial \\mathcal{L}}{\\partial y}</code></div>\n<div class=\"formula\"><code>g_z = \\frac{\\partial \\mathcal{L}}{\\partial z}</code></div>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial w} = g_z x</code></div>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial b} = g_z</code></div>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial x} = g_z w</code></div>\n<p>This is the core idea. Each operation receives a gradient from the operation after it, multiplies it by its own local derivative, and passes the result backward.</p>\n<p>The full neural network is just the same mechanism repeated many times, with vectors and matrices instead of single numbers.</p>\n<h2>Nonlinearities, tanh saturation, and local gradient attenuation</h2>\n<p>Now that we have seen what happens to a Gaussian distribution when it is passed through a <code>tanh</code>, and now that we know the local derivative of <code>tanh</code>, we can connect the two ideas.</p>\n<p>The question is: what kind of distribution does a neuron see before the nonlinearity?</p>\n<p>Consider one neuron:</p>\n<div class=\"formula\"><code>z = w_1x_1 + w_2x_2 + \\dots + w_nx_n + b</code></div>\n<p>or, more compactly:</p>\n<div class=\"formula\"><code>z = \\sum_{i=1}^{n} w_i x_i + b</code></div>\n<p>This value <code class=\"formula-inline\">z</code> is the pre-activation. It is the input of the tanh:</p>\n<div class=\"formula\"><code>h = \\tanh(z)</code></div>\n<p>To understand the scale of <code class=\"formula-inline\">z</code>, we need two basic facts about variance.</p>\n<p>First, if random variables are independent, the variance of their sum is the sum of their variances:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(X_1 + X_2 + \\dots + X_n)\n=\n\\sum_{i=1}^{n} \\mathrm{Var}(X_i)</code></div>\n<p>Second, multiplying a random variable by a constant scales the variance by the square of that constant:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(aX) = a^2 \\mathrm{Var}(X)</code></div>\n<p>In our neuron, each term is <code class=\"formula-inline\">w_i x_i</code>. If we assume that the inputs and weights are independent and centered around zero, then the variance of the pre-activation is approximately:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n=\n\\sum_{i=1}^{n} \\mathrm{Var}(w_i x_i)</code></div>\n<p>and, under the usual independence assumptions:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n\\approx\nn \\, \\mathrm{Var}(w) \\, \\mathrm{Var}(x)</code></div>\n<p>From now on, assume that the input activations are normalized so that:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(x) \\approx 1</code></div>\n<p>Then the expression becomes:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n\\approx\nn \\, \\mathrm{Var}(w)</code></div>\n<p>This is the key point: under this assumption, the variance of the pre-activation grows with the number of inputs, also called <code>fan_in</code>.</p>\n<p>If the weights are initialized too large, then <code class=\"formula-inline\">\\mathrm{Var}(w)</code> is large, so <code class=\"formula-inline\">\\mathrm{Var}(z)</code> becomes large. This means that many pre-activations fall far from zero.</p>\n<p>But we already saw what tanh does in that case: it pushes large positive values close to <code class=\"formula-inline\">1</code> and large negative values close to <code class=\"formula-inline\">-1</code>.</p>\n<p>So if <code class=\"formula-inline\">z</code> has high variance, then:</p>\n<div class=\"formula\"><code>h = \\tanh(z)</code></div>\n<p>will be concentrated near <code class=\"formula-inline\">-1</code> and <code class=\"formula-inline\">1</code>.</p>\n<p>This is a problem for backpropagation because the local derivative of tanh is:</p>\n<div class=\"formula\"><code>\\frac{\\partial h}{\\partial z}\n=\n1 - h^2</code></div>\n<p>If <code class=\"formula-inline\">h \\approx 1</code> or <code class=\"formula-inline\">h \\approx -1</code>, then:</p>\n<div class=\"formula\"><code>1 - h^2 \\approx 0</code></div>\n<p>So the gradient that flows backward through the tanh gets multiplied by a number close to zero:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial z}\n=\n\\frac{\\partial \\mathcal{L}}{\\partial h}\n(1 - h^2)</code></div>\n<p>This is local gradient attenuation. If many neurons are saturated, many gradients are killed locally. This is one way vanishing gradients appear in practice.</p>\n<p>We can see this directly in the network by plotting the hidden activations. Here we are still using the naive hidden-layer initialization: <code>W1</code> is not fan-in normalized yet, while <code>W2</code> is already scaled down to fix the initial output confidence.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.hist(h.view(</span><span>-</span><span>1</span><span>).tolist(), </span><span>50</span><span>)</span><span>;</span></span></code></pre>\n<p>If a large amount of mass is close to <code>-1</code> and <code>1</code>, the hidden layer is saturated.</p>\n<p>An even clearer diagnostic is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>10</span><span>, </span><span>20</span><span>))</span></span>\n<span class=\"line\"><span>plt.imshow(h.abs() </span><span>&gt;</span><span> 0.99</span><span>, </span><span>cmap</span><span>=</span><span>\"grey\"</span><span>, </span><span>interpolation</span><span>=</span><span>\"nearest\"</span><span>)</span><span>;</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/tanh-saturation-map-1aa907ca.webp\" alt=\"Tanh saturation map\" /></p>\n<p>Each white point is an activation with absolute value greater than <code>0.99</code>. These are neurons that are almost fully saturated. For those values, the local tanh derivative is almost zero, so the gradient does not flow well through them.</p>\n<p>The opposite problem is when the weights are initialized too small.</p>\n<p>If <code class=\"formula-inline\">\\mathrm{Var}(w)</code> is too small, then:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n\\approx\nn \\, \\mathrm{Var}(w) \\, \\mathrm{Var}(x)</code></div>\n<p>also becomes too small. In this case most pre-activations are very close to zero.</p>\n<p>For tanh, this does not kill the local gradient, because around zero:</p>\n<div class=\"formula\"><code>\\tanh(z) \\approx z</code></div>\n<p>and:</p>\n<div class=\"formula\"><code>\\tanh'(z) \\approx 1</code></div>\n<p>So the tanh itself is not saturated. The problem is more subtle: the activations are small because the pre-activations are small.</p>\n<p>If the next layer is:</p>\n<div class=\"formula\"><code>z_{next} = hW_{next} + b_{next}</code></div>\n<p>then the gradient of the loss with respect to the next weight matrix has the usual form:</p>\n<div class=\"formula\"><code>\\frac{\\partial \\mathcal{L}}{\\partial W_{next}}\n=\nh^T \\delta_{next}</code></div>\n<p>where <code class=\"formula-inline\">\\delta_{next}</code> is the gradient arriving from the next pre-activation. So if <code class=\"formula-inline\">h</code> is very small, the weight gradient is also small. The update becomes small not because tanh blocked the gradient locally, but because the activation that multiplies the gradient is tiny.</p>\n<p>There is a second effect in the backward pass. The gradient sent to the previous layer is:</p>\n<div class=\"formula\"><code>\\delta_{prev}\n=\n\\delta_{next} W_{next}^T</code></div>\n<p>If the weights are very small, this backward signal is also scaled down. So with weights that are too small, the network can end up with small activations in the forward pass and small gradients in the backward pass.</p>\n<p>So we want a middle ground:</p>\n<ul>\n<li>not too large, otherwise tanh saturates and gradients vanish;</li>\n<li>not too small, otherwise the signal becomes tiny;</li>\n<li>roughly stable variance from layer to layer.</li>\n</ul>\n<p>It is important to be precise about what we are analyzing here. This is an initialization problem. We are looking at what happens at the first forward pass, before training has had the chance to move the parameters.</p>\n<p>Later, during training, similar problems can appear again: activations can drift, distributions can shift, and neurons can still move into saturated regions. That is a different problem, and later we will look at mechanisms designed to keep activations well behaved during training.</p>\n<p>For now, we only want a good starting point.</p>\n<p>From:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n\\approx\nn \\, \\mathrm{Var}(w) \\, \\mathrm{Var}(x)</code></div>\n<p>and assuming:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(x) \\approx 1</code></div>\n<p>we get:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z)\n\\approx\nn \\, \\mathrm{Var}(w)</code></div>\n<p>So if we want:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(z) \\approx 1</code></div>\n<p>we need:</p>\n<div class=\"formula\"><code>n \\, \\mathrm{Var}(w) \\approx 1</code></div>\n<p>which means:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(w) \\approx \\frac{1}{n}</code></div>\n<p>and therefore:</p>\n<div class=\"formula\"><code>\\mathrm{Std}(w) \\approx \\frac{1}{\\sqrt{n}}</code></div>\n<p>This gives us a very simple first correction: scale the weights by the inverse square root of the fan-in.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>W1 </span><span>=</span><span> torch.randn((n_embd </span><span>*</span><span> block_size, n_hidden), </span><span>generator</span><span>=</span><span>g) </span><span>*</span><span> (</span><span>1</span><span> /</span><span> (n_embd </span><span>*</span><span> block_size)</span><span>**</span><span>0.5</span><span>)</span></span></code></pre>\n<p>This does not solve every training problem, but it fixes the first obvious one: the pre-activations are no longer exploding just because each neuron is summing many independent inputs.</p>\n<p>After this change, we can rerun the same saturation diagnostic:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>10</span><span>, </span><span>20</span><span>))</span></span>\n<span class=\"line\"><span>plt.imshow(h.abs() </span><span>&gt;</span><span> 0.99</span><span>, </span><span>cmap</span><span>=</span><span>\"grey\"</span><span>, </span><span>interpolation</span><span>=</span><span>\"nearest\"</span><span>)</span><span>;</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/tanh-saturation-map-fan-in-75f334ec.webp\" alt=\"Tanh saturation map after fan-in scaling\" /></p>\n<p>We can also look again at the activation histogram:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>plt.hist(h.view(</span><span>-</span><span>1</span><span>).tolist(), </span><span>50</span><span>)</span><span>;</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/tanh-activation-hist-fan-in-417cc91e.webp\" alt=\"Tanh activation histogram after fan-in scaling\" /></p>\n<p>Now we see far fewer saturated activations, because the initial pre-activation variance has been brought back to a reasonable scale. Clearly, there are more advanced techniques to adjust weights at initialization, such as <a href=\"https://proceedings.mlr.press/v9/glorot10a.html\" rel=\"noopener noreferrer\">Xavier/Glorot initialization</a>, <a href=\"https://arxiv.org/abs/1502.01852\" rel=\"noopener noreferrer\">He/Kaiming initialization</a>, and <a href=\"https://arxiv.org/abs/1901.09321\" rel=\"noopener noreferrer\">Fixup initialization</a>.</p>\n<p>But later we are going to see that we can introduce some architectural changes that helps us take in control both the initialization and in training variance problem.</p>\n<h2>Modern stabilization mechanisms</h2>\n<p>In this section we are going to see some of the most common techniques used in modern neural networks to keep activations and gradients well behaved during training. These techniques are not just for initialization: they help maintain stable distributions throughout training, which is crucial for deep networks.</p>\n<h3>Batch Normalization</h3>\n<p>Batch Normalization was introduced by Sergey Ioffe and Christian Szegedy in the Google paper <a href=\"https://arxiv.org/abs/1502.03167\" rel=\"noopener noreferrer\">Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift</a>.</p>\n<p>Until now we mostly reasoned about initialization. We asked: if the input variance is approximately one, how should we initialize the weights so that the pre-activation variance does not explode or collapse at the first forward pass?</p>\n<p>That is important, but it is only the first forward pass.</p>\n<p>During training, the weights of the previous layers keep changing. So the distribution seen by a layer also keeps changing. A layer may start with reasonable pre-activations, but after some updates those pre-activations can shift, widen, shrink, or move into the saturated region of the nonlinearity. This is the idea that the BatchNorm paper calls internal covariate shift: the input distribution of internal layers is not stable while the model is training.</p>\n<p>The idea of BatchNorm is simple:</p>\n<blockquote>\n<p>before sending the pre-activations into the nonlinearity, normalize them over the mini-batch.</p>\n</blockquote>\n<p>In our case, the block becomes:</p>\n<div class=\"formula\"><code>z = xW + b</code></div>\n<div class=\"formula\"><code>\\tilde{z} = \\mathrm{BatchNorm}(z)</code></div>\n<div class=\"formula\"><code>h = \\tanh(\\tilde{z})</code></div>\n<p>So the <code>tanh</code> does not directly see the raw linear output anymore. It sees a normalized version of it.</p>\n<p>To understand the mechanism, start from the usual standardization formula. If:</p>\n<div class=\"formula\"><code>X \\sim \\mathcal{N}(\\mu, \\sigma^2)</code></div>\n<p>then:</p>\n<div class=\"formula\"><code>Z = \\frac{X - \\mu}{\\sigma}</code></div>\n<p>has:</p>\n<div class=\"formula\"><code>\\mathbb{E}[Z] = 0</code></div>\n<p>and:</p>\n<div class=\"formula\"><code>\\mathrm{Var}(Z) = 1</code></div>\n<p>If the original variable is Gaussian, then this produces a standard Gaussian:</p>\n<div class=\"formula\"><code>Z \\sim \\mathcal{N}(0, 1)</code></div>\n<p>BatchNorm applies exactly this idea inside the network, but using mini-batch statistics instead of the true population mean and variance.</p>\n<p>Suppose the linear layer output has shape:</p>\n<div class=\"formula\"><code>z \\in \\mathbb{R}^{m \\times d}</code></div>\n<p>where <code>m</code> is the batch size and <code>d</code> is the number of neurons. Each column of <code>z</code> is one neuron evaluated over all examples in the batch. So BatchNorm normalizes each neuron independently across the batch.</p>\n<p>For one neuron, we have the mini-batch values:</p>\n<div class=\"formula\"><code>\\mathcal{B} = \\{z_1, z_2, \\dots, z_m\\}</code></div>\n<p>The batch mean is:</p>\n<div class=\"formula\"><code>\\mu_{\\mathcal{B}} = \\frac{1}{m}\\sum_{i=1}^{m} z_i</code></div>\n<p>The batch variance is:</p>\n<div class=\"formula\"><code>\\sigma_{\\mathcal{B}}^2 = \\frac{1}{m}\\sum_{i=1}^{m}(z_i - \\mu_{\\mathcal{B}})^2</code></div>\n<p>Then we normalize:</p>\n<div class=\"formula\"><code>\\hat{z}_i = \\frac{z_i - \\mu_{\\mathcal{B}}}{\\sqrt{\\sigma_{\\mathcal{B}}^2 + \\epsilon}}</code></div>\n<p>The small <code class=\"formula-inline\">\\epsilon</code> is only for numerical stability, so we never divide by zero.</p>\n<p>At this point, for each neuron, the batch of normalized values has approximately zero mean and unit variance. This does not magically make every distribution Gaussian, but if the pre-activation is already roughly Gaussian, it brings it close to a standard Gaussian.</p>\n<p>There is one more important detail. If we forced every layer to always use zero-mean and unit-variance activations, we would reduce what the network can represent. Sometimes the network may actually want a shifted or scaled version of the normalized activation.</p>\n<p>So BatchNorm adds two learnable parameters per neuron:</p>\n<div class=\"formula\"><code>y_i = \\gamma \\hat{z}_i + \\beta</code></div>\n<p>where:</p>\n<ul>\n<li><code class=\"formula-inline\">\\gamma</code> is a learnable scale</li>\n<li><code class=\"formula-inline\">\\beta</code> is a learnable shift</li>\n</ul>\n<p>This is crucial. BatchNorm normalizes the signal, but then gives the model the freedom to learn the right scale and offset again if that is useful.</p>\n<p>This is the implementation used in the notebook:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> BatchNormalizationLayer</span><span>:</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, fan_out, eps</span><span>=</span><span>1e-5</span><span>, momentum</span><span>=</span><span>0.1</span><span>):</span></span>\n<span class=\"line\"><span>        self</span><span>.eps </span><span>=</span><span> eps</span></span>\n<span class=\"line\"><span>        self</span><span>.momentum </span><span>=</span><span> momentum</span></span>\n<span class=\"line\"><span>        self</span><span>.training </span><span>=</span><span> True</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        self</span><span>.gamma </span><span>=</span><span> torch.ones(fan_out, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.beta </span><span>=</span><span> torch.zeros(fan_out, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        self</span><span>.running_mean </span><span>=</span><span> torch.zeros(fan_out)</span></span>\n<span class=\"line\"><span>        self</span><span>.running_var </span><span>=</span><span> torch.ones(fan_out)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> __call__</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        if</span><span> self</span><span>.training:</span></span>\n<span class=\"line\"><span>            batch_mean </span><span>=</span><span> x.mean(</span><span>0</span><span>)</span></span>\n<span class=\"line\"><span>            batch_var </span><span>=</span><span> x.var(</span><span>0</span><span>, </span><span>unbiased</span><span>=</span><span>False</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            x_normalized </span><span>=</span><span> (x </span><span>-</span><span> batch_mean) </span><span>/</span><span> torch.sqrt(batch_var </span><span>+</span><span> self</span><span>.eps)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            with</span><span> torch.no_grad():</span></span>\n<span class=\"line\"><span>                self</span><span>.running_mean </span><span>=</span><span> (</span><span>1</span><span> -</span><span> self</span><span>.momentum) </span><span>*</span><span> self</span><span>.running_mean </span><span>+</span><span> self</span><span>.momentum </span><span>*</span><span> batch_mean</span></span>\n<span class=\"line\"><span>                self</span><span>.running_var </span><span>=</span><span> (</span><span>1</span><span> -</span><span> self</span><span>.momentum) </span><span>*</span><span> self</span><span>.running_var </span><span>+</span><span> self</span><span>.momentum </span><span>*</span><span> batch_var</span></span>\n<span class=\"line\"><span>        else</span><span>:</span></span>\n<span class=\"line\"><span>            x_normalized </span><span>=</span><span> (x </span><span>-</span><span> self</span><span>.running_mean) </span><span>/</span><span> torch.sqrt(</span><span>self</span><span>.running_var </span><span>+</span><span> self</span><span>.eps)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> self</span><span>.gamma </span><span>*</span><span> x_normalized </span><span>+</span><span> self</span><span>.beta</span></span>\n<span class=\"line\"><span>        self</span><span>.out </span><span>=</span><span> out</span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> parameters</span><span>(self):</span></span>\n<span class=\"line\"><span>        return</span><span> [</span><span>self</span><span>.gamma, </span><span>self</span><span>.beta]</span></span></code></pre>\n<p>The important line is:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>batch_mean </span><span>=</span><span> x.mean(</span><span>0</span><span>)</span></span>\n<span class=\"line\"><span>batch_var </span><span>=</span><span> x.var(</span><span>0</span><span>, </span><span>unbiased</span><span>=</span><span>False</span><span>)</span></span></code></pre>\n<p>The dimension <code>0</code> is the batch dimension. So we are not computing one global mean over the whole matrix. We are computing one mean and one variance for each neuron.</p>\n<p>Then:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x_normalized </span><span>=</span><span> (x </span><span>-</span><span> batch_mean) </span><span>/</span><span> torch.sqrt(batch_var </span><span>+</span><span> self</span><span>.eps)</span></span></code></pre>\n<p>standardizes every column independently.</p>\n<p>The parameters:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>self</span><span>.gamma </span><span>=</span><span> torch.ones(fan_out, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>self</span><span>.beta </span><span>=</span><span> torch.zeros(fan_out, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span></code></pre>\n<p>are the learnable scale and shift. They are returned by <code>parameters()</code>, so the optimizer updates them like normal weights.</p>\n<p>The running statistics are needed because training and inference are different. During training, using the current mini-batch statistics is fine. During inference, instead, we do not want the prediction for one example to depend on the other examples that happened to be in the same batch. So we use the running estimates accumulated during training:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>self</span><span>.running_mean </span><span>=</span><span> (</span><span>1</span><span> -</span><span> self</span><span>.momentum) </span><span>*</span><span> self</span><span>.running_mean </span><span>+</span><span> self</span><span>.momentum </span><span>*</span><span> batch_mean</span></span>\n<span class=\"line\"><span>self</span><span>.running_var </span><span>=</span><span> (</span><span>1</span><span> -</span><span> self</span><span>.momentum) </span><span>*</span><span> self</span><span>.running_var </span><span>+</span><span> self</span><span>.momentum </span><span>*</span><span> batch_var</span></span></code></pre>\n<p>In the model, we insert BatchNorm after the linear layer and before the <code>tanh</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>layers </span><span>=</span><span> [</span></span>\n<span class=\"line\"><span>   Linear(n_embd </span><span>*</span><span> block_size, n_hidden),</span></span>\n<span class=\"line\"><span>   BatchNormalizationLayer(n_hidden),</span></span>\n<span class=\"line\"><span>   Tanh(),</span></span>\n<span class=\"line\"><span>   Linear(n_hidden, n_hidden),</span></span>\n<span class=\"line\"><span>   BatchNormalizationLayer(n_hidden),</span></span>\n<span class=\"line\"><span>   Tanh(),</span></span>\n<span class=\"line\"><span>   Linear(n_hidden, vocab_size),</span></span>\n<span class=\"line\"><span>]</span></span></code></pre>\n<p>This placement matters. The paper also discusses applying BatchNorm before the nonlinearity, because the goal is to keep the nonlinearity input in a controlled range. For us this means: normalize the pre-activation, then apply <code>tanh</code>.</p>\n<p>There is also a small practical consequence: if we normalize the output of <code>xW + b</code>, the bias before BatchNorm becomes less important, because subtracting the batch mean removes constant shifts. The learnable <code class=\"formula-inline\">\\beta</code> after normalization becomes the meaningful shift.</p>\n<p>We will inspect the concrete effect in the diagnostics section below. The important point here is conceptual: initialization tries to make the first step healthy; BatchNorm keeps re-normalizing the signal while the network is changing.</p>\n<h3>Layer Normalization</h3>\n<p>Layer Normalization was introduced by Ba, Kiros and Hinton in <a href=\"https://arxiv.org/abs/1607.06450\" rel=\"noopener noreferrer\">Layer Normalization</a>.</p>\n<p>The idea is close to BatchNorm, but the axis is different.</p>\n<p>BatchNorm normalizes each neuron using the statistics of the mini-batch. So it looks across examples:</p>\n<div class=\"formula\"><code>x \\in \\mathbb{R}^{batch \\times channels}</code></div>\n<p>LayerNorm, instead, normalizes the channel vector of a single example.</p>\n<p>This is the version that will be very useful when we move to transformers. In a transformer, each token is represented by a vector:</p>\n<div class=\"formula\"><code>x_t \\in \\mathbb{R}^{d}</code></div>\n<p>where <code class=\"formula-inline\">t</code> is the token position and <code class=\"formula-inline\">d</code> is the number of channels, or embedding dimensions.</p>\n<p>LayerNorm takes this vector and normalizes it across its channels:</p>\n<div class=\"formula\"><code>\\mu_t = \\frac{1}{d}\\sum_{j=1}^{d} x_{t,j}</code></div>\n<div class=\"formula\"><code>\\sigma_t^2 = \\frac{1}{d}\\sum_{j=1}^{d}(x_{t,j} - \\mu_t)^2</code></div>\n<div class=\"formula\"><code>\\hat{x}_{t,j} = \\frac{x_{t,j} - \\mu_t}{\\sqrt{\\sigma_t^2 + \\epsilon}}</code></div>\n<p>and then, as usual, it gives the model a learnable scale and shift:</p>\n<div class=\"formula\"><code>y_{t,j} = \\gamma_j \\hat{x}_{t,j} + \\beta_j</code></div>\n<p>The important intuition is this: for each token, LayerNorm asks \"is this token vector well scaled across its channels?\" It does not need to look at the other examples in the batch.</p>\n<p>That is why it fits transformers so naturally. When we predict the next token, every token representation is a channel vector that is repeatedly processed by attention and MLP blocks. LayerNorm keeps those token vectors in a reasonable scale before or after the block, depending on the architecture.</p>\n<p>So for now I only want to remember the operational difference:</p>\n<ul>\n<li>BatchNorm normalizes using the batch dimension</li>\n<li>LayerNorm normalizes using the channel dimension of each single token/example</li>\n</ul>\n<h3>Residual connections</h3>\n<p>Another modern mechanism that directly attacks the training problem is the residual connection, introduced by He, Zhang, Ren and Sun in <a href=\"https://arxiv.org/abs/1512.03385\" rel=\"noopener noreferrer\">Deep Residual Learning for Image Recognition</a>.</p>\n<p>The problem is very pragmatic. We would like to make networks deeper, because deeper networks should be able to build more abstract representations. But after some point, simply stacking more layers does not automatically help. The optimization becomes harder, gradients have to pass through many transformations, and the deeper model can even train worse than a shallower one.</p>\n<p>The residual idea is to stop forcing a block to learn the whole transformation from scratch.</p>\n<p>Without a residual connection, a block learns:</p>\n<div class=\"formula\"><code>y = F(x)</code></div>\n<p>With a residual connection, the block learns:</p>\n<div class=\"formula\"><code>y = x + F(x)</code></div>\n<p>This changes the meaning of what the block has to learn. The block is not asked to produce the full output anymore. It only has to learn a correction to the input.</p>\n<p>That is why the name is residual: <code class=\"formula-inline\">F(x)</code> is the residual part, the difference between what we already have and what we want.</p>\n<p>If the best thing for a layer is to do almost nothing, the residual block can learn:</p>\n<div class=\"formula\"><code>F(x) \\approx 0</code></div>\n<p>and then:</p>\n<div class=\"formula\"><code>y \\approx x</code></div>\n<p>So the block can behave like an identity mapping. This is important because adding more layers should not make the optimization problem worse just because the model has to rediscover how to copy information forward.</p>\n<p>There is also a direct gradient intuition. If:</p>\n<div class=\"formula\"><code>y = x + F(x)</code></div>\n<p>then:</p>\n<div class=\"formula\"><code>\\frac{\\partial y}{\\partial x}\n=\nI + \\frac{\\partial F(x)}{\\partial x}</code></div>\n<p>So during backpropagation the gradient has a direct path through the identity term. It does not have to pass only through the nonlinear block <code class=\"formula-inline\">F</code>. This does not mean gradients can never vanish or explode, but it gives the network a much cleaner route for information and gradients to flow through depth.</p>\n<p>In code, the idea is basically:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span>=</span><span> x </span><span>+</span><span> block(x)</span></span></code></pre>\n<p>or, if the dimensions need to be adapted:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span>=</span><span> projection(x) </span><span>+</span><span> block(x)</span></span></code></pre>\n<p>This is why residual connections fit naturally in this discussion. BatchNorm tries to keep the internal distributions under control. Good initialization tries to make the first forward/backward pass reasonable. Residual connections make depth easier by giving the network a stable path that can carry both activations and gradients across many layers.</p>\n<h3>Dropout</h3>\n<p>Dropout was introduced by Srivastava, Hinton, Krizhevsky, Sutskever and Salakhutdinov in <a href=\"https://jmlr.csail.mit.edu/papers/volume15/srivastava14a/srivastava14a.pdf\" rel=\"noopener noreferrer\">Dropout: A Simple Way to Prevent Neural Networks from Overfitting</a>.</p>\n<p>The motivation is different from BatchNorm and residual connections.</p>\n<p>BatchNorm and residual connections mainly help with optimization and signal flow. Dropout is mostly a regularization technique: it tries to prevent the network from fitting the training data too specifically.</p>\n<p>The idea is simple. During training, for each neuron we sample a Bernoulli random variable that decides if that neuron is active or not.</p>\n<div class=\"formula\"><code>m_i \\sim \\mathrm{Bernoulli}(p)</code></div>\n<p>where <code class=\"formula-inline\">p</code> is the probability that the neuron is kept active.</p>\n<p>If the sampled value is <code>1</code>, the neuron fires and its activation is used. If the sampled value is <code>0</code>, the neuron is switched off for that forward pass.</p>\n<p>So at every training step, the network is slightly different. Some neurons are present, some neurons do not fire. The model cannot rely too much on one specific activation always being there.</p>\n<p>The intuition is that dropout forces redundancy. If a feature is useful, the network should not encode it in one fragile path only. It should learn representations that still work even when some neurons are temporarily off.</p>\n<p>Dropout is not something I would add blindly to fix a broken training run. If activations are saturated or gradients are dead, dropout does not solve that. It can even make optimization noisier. But once the model trains and starts to overfit, dropout is a clean way to make the network less dependent on exact neuron co-adaptations.</p>\n<p>So in the mental map:</p>\n<ul>\n<li>initialization controls the first signal scale</li>\n<li>BatchNorm controls internal statistics during training</li>\n<li>residual connections help activations and gradients travel through depth</li>\n<li>dropout regularizes the representation by making it robust to missing units</li>\n</ul>\n<h2>Diagnostics and training KPIs</h2>\n<p>At this point, only looking at the loss is not enough.</p>\n<p>The loss tells us if the model is improving, but it does not tell us why the training is healthy or unhealthy. If the loss is bad, the problem could be saturated activations, gradients that are too small, gradients that are too large, or updates that are completely out of scale with the parameters.</p>\n<p>So we need a small diagnostic toolbox.</p>\n<h3>Loss curve</h3>\n<p>The first plot is still the loss curve. But we do not plot every single iteration, because that is too noisy. We average every 1000 iterations:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Now we can plot the loss curve to see how it evolves during training</span></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>10</span><span>, </span><span>5</span><span>))</span></span>\n<span class=\"line\"><span>lossi_avg </span><span>=</span><span> torch.tensor(lossi).view(</span><span>-</span><span>1</span><span>, </span><span>1000</span><span>).mean(</span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>plt.plot(lossi_avg, </span><span>label</span><span>=</span><span>\"Training Loss (log10)\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.axvline(</span><span>100</span><span>, </span><span>color</span><span>=</span><span>\"r\"</span><span>, </span><span>linestyle</span><span>=</span><span>\"--\"</span><span>, </span><span>label</span><span>=</span><span>\"learning-rate decay\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.xlabel(</span><span>\"1000-iteration blocks\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.ylabel(</span><span>\"log10(loss)\"</span><span>)</span></span>\n<span class=\"line\"><span>plt.legend()</span></span>\n<span class=\"line\"><span>plt.show()</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/diagnostic-loss-curve-batchnorm-2f2aee05.webp\" alt=\"Diagnostic loss curve\" /></p>\n<p>This gives us the global view. The model improves quickly at the beginning, then the curve becomes flatter. The red dashed line is the learning-rate decay: after 100 blocks, so after <code>100000</code> iterations, the learning rate goes from <code>0.1</code> to <code>0.01</code>.</p>\n<p>This plot is useful, but it is not enough. It tells us that training is moving, but it does not tell us what is happening inside the network.</p>\n<h3>Saturated activations</h3>\n<p>Since we are using <code>tanh</code>, the first thing I want to inspect is saturation. A <code>tanh</code> neuron is saturated when its output is very close to <code>-1</code> or <code>1</code>. In that region, the local derivative is close to zero, so the gradient has a hard time flowing backward.</p>\n<p>The following plot shows, for each <code>Tanh</code> layer, which activations are saturated:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Visualize saturated activations for the two Tanh layers</span></span>\n<span class=\"line\"><span>tanh_layers </span><span>=</span><span> [(i, layer) </span><span>for</span><span> i, layer </span><span>in</span><span> enumerate</span><span>(layers[:</span><span>-</span><span>1</span><span>]) </span><span>if</span><span> isinstance</span><span>(layer, Tanh)]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>10</span><span>, </span><span>4</span><span> *</span><span> len</span><span>(tanh_layers)))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> k, (i, layer) </span><span>in</span><span> enumerate</span><span>(tanh_layers, </span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    t </span><span>=</span><span> layer.out.detach()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    plt.subplot(</span><span>len</span><span>(tanh_layers), </span><span>1</span><span>, k)</span></span>\n<span class=\"line\"><span>    plt.imshow((t.abs() </span><span>&gt;</span><span> 0.99</span><span>).float(), </span><span>cmap</span><span>=</span><span>\"gray\"</span><span>, </span><span>interpolation</span><span>=</span><span>\"nearest\"</span><span>, </span><span>aspect</span><span>=</span><span>\"auto\"</span><span>)</span></span>\n<span class=\"line\"><span>    plt.title(</span><span>f</span><span>'layer </span><span>{</span><span>i</span><span>}</span><span> (</span><span>{</span><span>layer.</span><span>__class__</span><span>.</span><span>__name__}</span><span>) | saturated activations'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.xlabel(</span><span>'neuron index'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.ylabel(</span><span>'batch example'</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/diagnostic-tanh-saturation-by-layer-fb86375b.webp\" alt=\"Tanh saturation diagnostic by layer\" /></p>\n<p>Here the x-axis is the neuron index and the y-axis is the batch example. White pixels are activations with:</p>\n<div class=\"formula\"><code>|h| &gt; 0.99</code></div>\n<p>This plot is better than a single percentage because it preserves the structure. If we see a full vertical white stripe, that neuron is saturated for almost every example in the batch. That would be a bad sign. If instead we see sparse white pixels, then some examples are saturating but the whole layer is not dead.</p>\n<p>In this run, the first <code>Tanh</code> layer is still more saturated than the second one. So BatchNorm helped us control the signal, but it did not magically remove all saturation.</p>\n<h3>Activation distributions</h3>\n<p>The saturation map is useful, but I also want the distribution view. The histogram tells us where the mass of the activations is.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Inspect activation distributions layer by layer</span></span>\n<span class=\"line\"><span>tanh_layers </span><span>=</span><span> [(i, layer) </span><span>for</span><span> i, layer </span><span>in</span><span> enumerate</span><span>(layers[:</span><span>-</span><span>1</span><span>]) </span><span>if</span><span> isinstance</span><span>(layer, Tanh)]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>n </span><span>=</span><span> len</span><span>(tanh_layers)</span></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>4</span><span> *</span><span> n, </span><span>3</span><span>))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> k, (i, layer) </span><span>in</span><span> enumerate</span><span>(tanh_layers, </span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    t </span><span>=</span><span> layer.out.detach().view(</span><span>-</span><span>1</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    mean </span><span>=</span><span> t.mean().item()</span></span>\n<span class=\"line\"><span>    std </span><span>=</span><span> t.std().item()</span></span>\n<span class=\"line\"><span>    sat </span><span>=</span><span> (t.abs() </span><span>&gt;</span><span> 0.97</span><span>).float().mean().item() </span><span>*</span><span> 100</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>'layer </span><span>{</span><span>i</span><span>:2d</span><span>}</span><span> (</span><span>{</span><span>layer.</span><span>__class__</span><span>.</span><span>__name__</span><span>:&gt;10s</span><span>}</span><span>) | mean </span><span>{</span><span>mean</span><span>:+.2f</span><span>}</span><span> | std </span><span>{</span><span>std</span><span>:.2f</span><span>}</span><span> | saturated </span><span>{</span><span>sat</span><span>:.2f</span><span>}</span><span>%'</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    plt.subplot(</span><span>1</span><span>, n, k)</span></span>\n<span class=\"line\"><span>    plt.hist(t.tolist(), </span><span>bins</span><span>=</span><span>40</span><span>, </span><span>density</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>    plt.title(</span><span>f</span><span>'layer </span><span>{</span><span>i</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.xlabel(</span><span>f</span><span>'μ=</span><span>{</span><span>mean</span><span>:+.2f</span><span>}</span><span>, σ=</span><span>{</span><span>std</span><span>:.2f</span><span>}\\n</span><span>sat=</span><span>{</span><span>sat</span><span>:.1f</span><span>}</span><span>%'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.ylim(</span><span>bottom</span><span>=</span><span>0</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.suptitle(</span><span>'Activation distributions by layer'</span><span>)</span></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>layer  2 (      Tanh) | mean -0.01 | std 0.74 | saturated 19.19%</span></span>\n<span class=\"line\"><span>layer  5 (      Tanh) | mean -0.01 | std 0.64 | saturated 7.50%</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/diagnostic-activation-distributions-by-layer-d7d14b80.webp\" alt=\"Activation distributions by layer\" /></p>\n<p>This confirms the same story. The means are close to zero, which is good. But the first layer has a larger standard deviation and more mass close to <code>-1</code> and <code>1</code>, so it has more saturated activations.</p>\n<p>This is the kind of plot that makes the problem visible: we are not just saying \"maybe tanh saturates\". We can actually inspect where and how much it is happening.</p>\n<h3>Gradient distributions</h3>\n<p>Now we look at the backward pass.</p>\n<p>After <code>loss.backward()</code>, each layer output has a gradient. For the <code>Tanh</code> layers, this tells us the gradient signal that is flowing backward through those activations:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Inspect gradient distributions layer by layer</span></span>\n<span class=\"line\"><span># We want gradients that are not collapsed to zero and not exploding.</span></span>\n<span class=\"line\"><span>tanh_layers </span><span>=</span><span> [(i, layer) </span><span>for</span><span> i, layer </span><span>in</span><span> enumerate</span><span>(layers[:</span><span>-</span><span>1</span><span>]) </span><span>if</span><span> isinstance</span><span>(layer, Tanh)]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>n </span><span>=</span><span> len</span><span>(tanh_layers)</span></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>4</span><span> *</span><span> n, </span><span>3</span><span>))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> k, (i, layer) </span><span>in</span><span> enumerate</span><span>(tanh_layers, </span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    t </span><span>=</span><span> layer.out.grad.detach().view(</span><span>-</span><span>1</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    mean </span><span>=</span><span> t.mean().item()</span></span>\n<span class=\"line\"><span>    std </span><span>=</span><span> t.std().item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>'layer </span><span>{</span><span>i</span><span>:2d</span><span>}</span><span> (</span><span>{</span><span>layer.</span><span>__class__</span><span>.</span><span>__name__</span><span>:&gt;10s</span><span>}</span><span>) | mean </span><span>{</span><span>mean</span><span>:+.3e</span><span>}</span><span> | std </span><span>{</span><span>std</span><span>:.3e</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    plt.subplot(</span><span>1</span><span>, n, k)</span></span>\n<span class=\"line\"><span>    plt.hist(t.tolist(), </span><span>bins</span><span>=</span><span>40</span><span>, </span><span>density</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>    plt.title(</span><span>f</span><span>'layer </span><span>{</span><span>i</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.xlabel(</span><span>f</span><span>'μ=</span><span>{</span><span>mean</span><span>:+.1e</span><span>}</span><span>, σ=</span><span>{</span><span>std</span><span>:.1e</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.ylim(</span><span>bottom</span><span>=</span><span>0</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.suptitle(</span><span>'Gradient distributions by layer'</span><span>)</span></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>layer  2 (      Tanh) | mean -3.638e-12 | std 3.133e-03</span></span>\n<span class=\"line\"><span>layer  5 (      Tanh) | mean -2.237e-05 | std 5.294e-03</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/diagnostic-gradient-distributions-by-layer-0124301e.webp\" alt=\"Gradient distributions by layer\" /></p>\n<p>The mean is close to zero, and that is not a problem. Gradients have signs, so positive and negative values can cancel.</p>\n<p>The more interesting quantity here is the standard deviation. If the gradient distribution is collapsed almost exactly at zero, the layer is not receiving a useful learning signal. If it is extremely wide, then the backward signal may be unstable.</p>\n<p>So here I do not want \"large variance\" in an absolute sense. I want visible, non-collapsed variance: gradients should carry information, but not explode.</p>\n<h3>Weight gradient distributions</h3>\n<p>Finally, we inspect the gradients of the parameters themselves.</p>\n<p>For each weight matrix, we plot the distribution of its gradients and compute:</p>\n<div class=\"formula\"><code>\\frac{\\mathrm{std}(\\nabla W)}{\\mathrm{std}(W)}</code></div>\n<p>This is a scale-aware diagnostic. A gradient with standard deviation <code>0.01</code> may be big or small depending on the scale of the parameter it is updating. So we compare gradient scale against weight scale.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Inspect weight gradient distributions parameter by parameter</span></span>\n<span class=\"line\"><span>weight_params </span><span>=</span><span> [(i, p) </span><span>for</span><span> i, p </span><span>in</span><span> enumerate</span><span>(parameters) </span><span>if</span><span> p.ndim </span><span>==</span><span> 2</span><span>]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>n </span><span>=</span><span> len</span><span>(weight_params)</span></span>\n<span class=\"line\"><span>plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>8</span><span>, </span><span>3</span><span> *</span><span> n))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> k, (i, p) </span><span>in</span><span> enumerate</span><span>(weight_params, </span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    t </span><span>=</span><span> p.grad.detach().view(</span><span>-</span><span>1</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    mean </span><span>=</span><span> t.mean().item()</span></span>\n<span class=\"line\"><span>    std </span><span>=</span><span> t.std().item()</span></span>\n<span class=\"line\"><span>    ratio </span><span>=</span><span> (p.grad.std() </span><span>/</span><span> p.std()).item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>'weight </span><span>{tuple</span><span>(p.shape)</span><span>!s:&gt;12s</span><span>}</span><span> | mean </span><span>{</span><span>mean</span><span>:+.3e</span><span>}</span><span> | std </span><span>{</span><span>std</span><span>:.3e</span><span>}</span><span> | grad:data ratio </span><span>{</span><span>ratio</span><span>:.3e</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    plt.subplot(n, </span><span>1</span><span>, k)   </span><span># one below the other</span></span>\n<span class=\"line\"><span>    plt.hist(t.tolist(), </span><span>bins</span><span>=</span><span>40</span><span>, </span><span>density</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>    plt.title(</span><span>f</span><span>'param </span><span>{</span><span>i</span><span>}</span><span> - shape </span><span>{tuple</span><span>(p.shape)</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.xlabel(</span><span>f</span><span>'μ=</span><span>{</span><span>mean</span><span>:+.1e</span><span>}</span><span>, σ=</span><span>{</span><span>std</span><span>:.1e</span><span>}</span><span>, ratio=</span><span>{</span><span>ratio</span><span>:.1e</span><span>}</span><span>'</span><span>)</span></span>\n<span class=\"line\"><span>    plt.ylim(</span><span>bottom</span><span>=</span><span>0</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>plt.suptitle(</span><span>'Weight gradient distributions'</span><span>)</span></span>\n<span class=\"line\"><span>plt.tight_layout()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>weight     (27, 10) | mean +3.311e-10 | std 1.384e-02 | grad:data ratio 1.252e-02</span></span>\n<span class=\"line\"><span>weight    (30, 200) | mean +1.307e-05 | std 7.136e-03 | grad:data ratio 1.838e-02</span></span>\n<span class=\"line\"><span>weight   (200, 200) | mean +1.558e-05 | std 4.308e-03 | grad:data ratio 2.301e-02</span></span>\n<span class=\"line\"><span>weight    (200, 27) | mean +2.208e-11 | std 1.813e-02 | grad:data ratio 8.428e-02</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/diagnostic-weight-gradient-distributions-06847694.webp\" alt=\"Weight gradient distributions\" /></p>\n<p>One important detail: this ratio tells us how large the gradient is compared to the weight. But during SGD the weight is not updated with the raw gradient. It is updated with the learning-rate-scaled gradient:</p>\n<div class=\"formula\"><code>\\Delta W = -\\eta \\nabla W</code></div>\n<p>So if we want to know how much the weight really moves, we have to include the learning rate:</p>\n<div class=\"formula\"><code>\\frac{\\mathrm{std}(\\Delta W)}{\\mathrm{std}(W)}\n=\n\\eta \\frac{\\mathrm{std}(\\nabla W)}{\\mathrm{std}(W)}</code></div>\n<p>For example, if:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>weight std    = 1</span></span>\n<span class=\"line\"><span>gradient std  = 0.01</span></span>\n<span class=\"line\"><span>learning rate = 0.01</span></span></code></pre>\n<p>then:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>grad:data ratio   = 0.01 / 1 = 1e-2</span></span>\n<span class=\"line\"><span>update:data ratio = (0.01 * 0.01) / 1 = 1e-4</span></span></code></pre>\n<p>So the plot tells us how strong the raw gradient signal is. To understand how much the parameters really move after one SGD step, we also have to multiply by the learning rate.</p>\n<p>This is the intuition I want from this plot:</p>\n<ul>\n<li>if the ratio is too small, the weights barely move</li>\n<li>if the ratio is too large, the weights are being hit too aggressively</li>\n<li>if one layer is very different from the others, that layer deserves attention</li>\n</ul>\n<p>So these plots are not just decoration. They are the basic instruments I want before trusting a training run: loss curve, activation saturation, activation distribution, gradient distribution, and parameter update scale.</p>\n<h2>Conclusion &amp; Sources</h2>\n<p>The main point of this post is that a neural network is not just a stack of matrix multiplications followed by <code>loss.backward()</code>.</p>\n<p>It is also a system that moves distributions forward and gradients backward. If the distributions are badly scaled, the nonlinearities saturate. If the gradients collapse, the model does not learn. If the updates are too large, training becomes unstable. The loss curve shows the final symptom, but the statistics of activations, gradients, and updates show the mechanism.</p>\n<p>So the intuition I want to keep is simple: before changing architectures randomly, inspect the signal. Look at means, variances, saturation, gradient distributions, and update ratios. These plots are not advanced tooling; they are the minimum instrumentation needed to understand if the network is actually trainable.</p>\n<p>Main practical reference:</p>\n<ul>\n<li>Andrej Karpathy, <a href=\"https://github.com/karpathy/nn-zero-to-hero\" rel=\"noopener noreferrer\">Neural Networks: Zero to Hero</a>, especially the <code>makemore</code> lectures on activations, gradients, BatchNorm, and manual backpropagation.</li>\n</ul>\n<p>Background math and signal/statistics intuition:</p>\n<ul>\n<li>Steven W. Smith, <a href=\"https://www.dspguide.com/\" rel=\"noopener noreferrer\">The Scientist and Engineer's Guide to Digital Signal Processing</a>.</li>\n<li><a href=\"https://en.wikipedia.org/wiki/Random_variable\" rel=\"noopener noreferrer\">Random variable</a>.</li>\n<li><a href=\"https://mathcenter.oxford.emory.edu/site/math117/besselCorrection/\" rel=\"noopener noreferrer\">Bessel correction</a>.</li>\n<li><a href=\"https://en.wikipedia.org/wiki/Law_of_large_numbers\" rel=\"noopener noreferrer\">Law of large numbers</a>.</li>\n<li><a href=\"https://en.wikipedia.org/wiki/Central_limit_theorem\" rel=\"noopener noreferrer\">Central limit theorem</a>.</li>\n</ul>\n<p>Papers cited:</p>\n<ul>\n<li>Yann LeCun, Leon Bottou, Genevieve B. Orr, Klaus-Robert Müller, <a href=\"https://yann.lecun.com/exdb/publis/pdf/lecun-98b.pdf\" rel=\"noopener noreferrer\">Efficient BackProp</a>, 1998.</li>\n<li>Xavier Glorot, Yoshua Bengio, <a href=\"https://proceedings.mlr.press/v9/glorot10a.html\" rel=\"noopener noreferrer\">Understanding the difficulty of training deep feedforward neural networks</a>, AISTATS 2010.</li>\n<li>Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun, <a href=\"https://arxiv.org/abs/1502.01852\" rel=\"noopener noreferrer\">Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification</a>, 2015.</li>\n<li>Sergey Ioffe, Christian Szegedy, <a href=\"https://arxiv.org/abs/1502.03167\" rel=\"noopener noreferrer\">Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift</a>, 2015.</li>\n<li>Jimmy Lei Ba, Jamie Ryan Kiros, Geoffrey E. Hinton, <a href=\"https://arxiv.org/abs/1607.06450\" rel=\"noopener noreferrer\">Layer Normalization</a>, 2016.</li>\n<li>Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun, <a href=\"https://arxiv.org/abs/1512.03385\" rel=\"noopener noreferrer\">Deep Residual Learning for Image Recognition</a>, 2015.</li>\n<li>Nitish Srivastava, Geoffrey Hinton, Alex Krizhevsky, Ilya Sutskever, Ruslan Salakhutdinov, <a href=\"https://jmlr.csail.mit.edu/papers/volume15/srivastava14a/srivastava14a.pdf\" rel=\"noopener noreferrer\">Dropout: A Simple Way to Prevent Neural Networks from Overfitting</a>, JMLR 2014.</li>\n<li>Hongyi Zhang, Yann N. Dauphin, Tengyu Ma, <a href=\"https://arxiv.org/abs/1901.09321\" rel=\"noopener noreferrer\">Fixup Initialization: Residual Learning Without Normalization</a>, 2019.</li>\n</ul>","date_published":"2026-04-21T00:00:00.000Z","tags":["Neural Networks","Deep Learning","Optimization","Initialization","Batch Normalization"]},{"id":"https://www.tommasovaccari.com/blog/engineering-movhex","url":"https://www.tommasovaccari.com/blog/engineering-movhex","title":"Engineering MovHex: Modeling and Optimizing a Dynamic Hex-Grid Router in C","summary":"Engineering a dynamic routing engine over a hexagonal map in C, from geometric modeling to shortest-path optimization.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2><strong>1. Introduction</strong></h2>\n<p>This post is a technical walkthrough of <strong>MovHex</strong>, my project for <em>Algorithms and Principles of Computer Science</em> at Politecnico di Milano.</p>\n<p>The assignment: build a routing engine over a rectangular map of hexagons. The program must support four commands:</p>\n<ol>\n<li><code>init</code> — create or reinitialize the map</li>\n<li><code>change_cost</code> — update hexagon costs within a hexagonal radius</li>\n<li><code>toggle_air_route</code> — add or remove a directed air route</li>\n<li><code>travel_cost</code> — answer shortest-path queries</li>\n</ol>\n<p>Concretely, the program must accept the following command formats:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>init &lt;n_columns&gt; &lt;n_rows&gt;</span></span>\n<span class=\"line\"><span>change_cost &lt;x&gt; &lt;y&gt; &lt;v&gt; &lt;radius&gt;</span></span>\n<span class=\"line\"><span>toggle_air_route &lt;x1&gt; &lt;y1&gt; &lt;x2&gt; &lt;y2&gt;</span></span>\n<span class=\"line\"><span>travel_cost &lt;xp&gt; &lt;yp&gt; &lt;xd&gt; &lt;yd&gt;</span></span></code></pre>\n<p>where:</p>\n<ul>\n<li><code>n_columns</code>, <code>n_rows</code> define the size of the rectangular hex map</li>\n<li><code>x</code>, <code>y</code> identify a hexagon using the specification's coordinate system</li>\n<li><code>v</code> is the signed update applied by <code>change_cost</code></li>\n<li><code>radius</code> is the positive hex-distance radius of the update</li>\n<li><code>x1</code>, <code>y1</code> are the source coordinates of a directed air route</li>\n<li><code>x2</code>, <code>y2</code> are the destination coordinates of a directed air route</li>\n<li><code>xp</code>, <code>yp</code> are the source coordinates of a shortest-path query</li>\n<li><code>xd</code>, <code>yd</code> are the destination coordinates of a shortest-path query</li>\n</ul>\n<p>At first glance this looks like \"implement Dijkstra on a graph.\" In practice, the real difficulty is in the interaction between hex geometry, mutable state, and performance constraints.</p>\n<p>The verifier for the course included explicit thresholds for <strong>30 cum laude</strong>:</p>\n<ul>\n<li>execution time below <strong>10 seconds</strong></li>\n<li>memory usage below <strong>26 MiB</strong></li>\n</ul>\n<p>My final version meets both thresholds.</p>\n<p>Correctness alone was not enough. The solution had to be modeled carefully, implemented safely in C, and then profiled and specialized until it was fast enough.</p>\n<p>Whenever I show code in this post, I show lightly reformatted excerpts from the final <code>main.c</code> in the repository. If a block contains <code>...</code>, that means omitted lines, not rewritten logic.</p>\n<hr />\n<h2><strong>2. The Problem, as the Specification Defines It</strong></h2>\n<p>The official statement describes a rectangular tiling of equal-sized hexagons. Hexagons are identified by <strong>column first, row second</strong>. Indices start from zero, increase left to right and bottom to top. The hexagon <code>(0,1)</code> sits on the upper-right side of <code>(0,0)</code>.</p>\n<p>That last detail is important: the coordinate system encodes an offset hex layout, and that affects both neighbor generation and distance computation.</p>\n<p><img src=\"https://www.tommasovaccari.com/static/coordinate-system-07d434ed.webp\" alt=\"Hex grid coordinate system\" /></p>\n<p><em>The coordinate system defined by the specification: columns grow left to right, rows grow bottom to top, and <code>(0,1)</code> sits on the upper-right side of <code>(0,0)</code>.</em></p>\n<p>Each hexagon stores a natural number in <code class=\"formula-inline\">[0,100]</code>:</p>\n<ul>\n<li>if positive, it is the ground exit cost of that hexagon</li>\n<li>if <code>0</code>, the hexagon can still be visited, but it cannot be abandoned — no ground or air departure is possible from it</li>\n</ul>\n<h3><strong>2.1 <code>init</code></strong></h3>\n<p><code>init n_columns n_rows</code> creates or reinitializes a map of <code>n_rows × n_columns</code> hexagons. All costs are set to 1, no air routes exist. The program responds <code>OK</code>.</p>\n<h3><strong>2.2 <code>change_cost</code></strong></h3>\n<p><code>change_cost x y v radius</code> updates every hexagon whose <strong>hex distance</strong> from <code>(x,y)</code> is <strong>strictly less</strong> than the given radius. The specification calls this distance <code>DistEsagoni</code> and defines it as the minimum number of hexagons traversed to reach the destination from the source, <strong>including the destination in the count</strong>, ignoring costs, blocked departures, and air routes.</p>\n<p>That \"including the destination\" clause pins the semantics: adjacent hexagons have distance 1, and the hexagon at the center has distance 0 from itself.</p>\n<p>In the rest of this post I refer to the same quantity as <code>hexDistance</code>, because that is the name I used while reasoning about the implementation. The formula and the semantics are the same as <code>DistEsagoni</code> in the specification.</p>\n<p>The update rule from the specification is:</p>\n<div class=\"formula\"><code>\\operatorname{cost}(x_e,y_e)=\\operatorname{cost}(x_e,y_e)+\\left\\lfloor v \\cdot \\max\\left(0,\\frac{r-\\operatorname{DistEsagoni}\\big((x_e,y_e),(x,y)\\big)}{r}\\right)\\right\\rfloor</code></div>\n<p>If I write <code class=\"formula-inline\">d = \\operatorname{hexDistance}\\big((x_e,y_e),(x,y)\\big)</code>, the additive delta is:</p>\n<div class=\"formula\"><code>\\Delta(d)=\\left\\lfloor v \\cdot \\max\\left(0,\\frac{r-d}{r}\\right)\\right\\rfloor</code></div>\n<p>The result is clamped to <code class=\"formula-inline\">[0,100]</code>. The same update also applies to <strong>all outgoing air routes</strong> of every affected hexagon. The program responds <code>KO</code> if <code>(x,y)</code> is not a valid hexagon or if <code>radius = 0</code>, otherwise <code>OK</code>.</p>\n<h3><strong>2.3 <code>toggle_air_route</code></strong></h3>\n<p><code>toggle_air_route x1 y1 x2 y2</code> adds or removes a <strong>directed</strong> air route. If the route does not exist, it is created. If it already exists, it is removed.</p>\n<p>When a new route is created, its cost is the <strong>floor of the average</strong> of all already existing outgoing air-route costs from <code>(x1,y1)</code> together with the ground exit cost of <code>(x1,y1)</code>. Each hexagon may have at most 5 outgoing air routes. The program responds <code>OK</code> if coordinates are valid and the limit is not exceeded, <code>KO</code> otherwise.</p>\n<p>When a route is removed, no cost recalculation happens — the route is simply deleted.</p>\n<h3><strong>2.4 <code>travel_cost</code></strong></h3>\n<p><code>travel_cost xp yp xd yd</code> asks for the minimum cost of reaching <code>(xd,yd)</code> from <code>(xp,yp)</code>. The rules:</p>\n<ol>\n<li>the exit cost of the <strong>destination</strong> hexagon is ignored</li>\n<li>if an air route is used, the <strong>ground exit cost</strong> of the air route's source hexagon is ignored — you pay the air route's traversal cost instead</li>\n<li>if source equals destination, the cost is <strong>zero regardless of any other factor</strong> — even if the source has weight 0</li>\n<li>responds <code>-1</code> if coordinates are invalid or if no path exists</li>\n</ol>\n<p>That third rule matters. It means a weight-0 hexagon can still produce a valid <code>travel_cost</code> answer when it is both source and destination.</p>\n<h3><strong>2.5 The Workload Hint</strong></h3>\n<p>The specification gives one more important clue: in realistic inputs, <code>change_cost</code> and <code>toggle_air_route</code> are rare, while <code>travel_cost</code> is used heavily, and most source/destination pairs concentrate in the same zones.</p>\n<p>That hint shaped the whole approach:</p>\n<ul>\n<li>mutations can be more expensive, as long as they are correct</li>\n<li>shortest-path queries are the real hot path</li>\n<li>repeated queries deserve caching, but only with strict invalidation</li>\n</ul>\n<hr />\n<h2><strong>3. The Engineering Model</strong></h2>\n<p>The central design question was not \"which graph algorithm should I use?\" It was: what state does this problem actually require me to store permanently?</p>\n<p>The final implementation is built on four decisions:</p>\n<ol>\n<li>flatten the grid into a 1D array with row-major indexing</li>\n<li>treat ground adjacency as <strong>derived geometry</strong>, not stored state</li>\n<li>store air routes explicitly, because they are the only mutable adjacency</li>\n<li>keep shortest-path working memory separate from the permanent map</li>\n</ol>\n<h3><strong>3.1 Permanent State</strong></h3>\n<p>The permanent map consists of three parallel arrays:</p>\n<ul>\n<li><code>weight[]</code> (<code>uint8_t</code>) — the ground exit cost of each hexagon, in <code class=\"formula-inline\">[0,100]</code></li>\n<li><code>counter_air_route[]</code> (<code>uint8_t</code>) — the number of outgoing air routes from each node, in <code class=\"formula-inline\">[0,5]</code></li>\n<li><code>air_route[]</code> (<code>uint32_t*</code> per node) — a dynamically allocated array of destination indices for each node, <code>NULL</code> when the node has no outgoing air routes</li>\n</ul>\n<p>That is all. No adjacency lists for ground links, no per-route cost fields, no embedded graph metadata.</p>\n<h3><strong>3.2 Working State</strong></h3>\n<p>Dijkstra's algorithm needs its own data structures, allocated once after <code>init</code> and reused across every <code>travel_cost</code> query:</p>\n<ul>\n<li><code>distance[]</code> (<code>uint32_t</code>) — tentative distances from the source, initialized to <code>UINT32_MAX</code> at the start of each run via <code>memset(distance, 0xFF, ...)</code></li>\n<li>the priority structure (either a binary min-heap or bucket arrays, depending on the compile-time variant)</li>\n<li>a cache hash table for memoizing <code>travel_cost</code> results</li>\n</ul>\n<p>This separation matters. Ground neighbors are computed on the fly from coordinates and row parity. Air routes are stored because they mutate. Dijkstra metadata is transient and does not belong inside the map.</p>\n<h3><strong>3.3 Row-Major Indexing</strong></h3>\n<p>The mapping between 2D coordinates and the flat index is:</p>\n<div class=\"formula\"><code>\\text{index} = i \\cdot \\text{COLS} + j</code></div>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>static</span><span> inline</span><span> void</span><span> convert2to1</span><span>(</span><span>uint32_t</span><span> i</span><span>, </span><span>uint32_t</span><span> j</span><span>, </span><span>uint32_t</span><span> COLS</span><span>, </span><span>uint32_t</span><span> *</span><span>INDEX</span><span>){</span></span>\n<span class=\"line\"><span>    *</span><span>INDEX </span><span>=</span><span> i </span><span>*</span><span> COLS </span><span>+</span><span> j;</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>static</span><span> inline</span><span> void</span><span> convert1to2</span><span>(</span><span>uint32_t</span><span> *</span><span>i</span><span>, </span><span>uint32_t</span><span> *</span><span>j</span><span>, </span><span>uint32_t</span><span> COLS</span><span>, </span><span>uint32_t</span><span> INDEX</span><span>){</span></span>\n<span class=\"line\"><span>    *</span><span>i </span><span>=</span><span> INDEX </span><span>/</span><span> COLS;</span></span>\n<span class=\"line\"><span>    *</span><span>j </span><span>=</span><span> INDEX </span><span>%</span><span> COLS;</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The original implementation used a struct <code>hex_t</code> containing weight, distance, predecessor, ground-link lists, and air-route lists — all packed into one type. Profiling showed that the struct was too fat for cache lines, and most fields were only needed during Dijkstra. The final architecture is the result of dismantling that struct into flat parallel arrays and removing everything that could be derived instead of stored.</p>\n<hr />\n<h2><strong>4. The Geometric Core</strong></h2>\n<p>The first hard problem was not shortest paths. It was getting the geometry right.</p>\n<p>A square grid lets you get away with matrix intuition for a long time. A hex grid does not. Neighbor relations depend on row parity, and the specification numbers rows from bottom to top, while most standard hex-grid references assume top-based coordinates. Trying to reason in a matrix-like way immediately produces questions that do not exist on square grids: if I move to the upper-right neighbor, do I stay in the same column or shift? Does that answer change when the row is even?</p>\n<p>The external reference that made this click for me was the <a href=\"https://www.redblobgames.com/grids/hexagons/\" rel=\"noopener noreferrer\">Red Blob Games guide on hexagonal grids</a>. It gave me the right conceptual model: separate the problem into three coordinate layers instead of trying to make matrix intuition work directly.</p>\n<h3><strong>4.1 Three Coordinate Layers</strong></h3>\n<ol>\n<li><strong>Specification coordinates</strong>: column-first, row-second, rows numbered bottom to top</li>\n<li><strong>Offset layout</strong>: the physical hex grid where row parity shifts neighbor positions — in the specification's convention, odd rows are offset to the right</li>\n<li><strong>Cube coordinates</strong>: a 3D system <code class=\"formula-inline\">(q, r, s)</code> subject to the constraint <code class=\"formula-inline\">q + r + s = 0</code>, which makes distance computation clean and parity-independent</li>\n</ol>\n<p>The progression was concrete: matrix intuition broke because it does not account for the row-dependent offset, and only after recognizing the offset layout explicitly did the cube-coordinate machinery become applicable.</p>\n<h3><strong>4.2 Parity-Dependent Neighbors</strong></h3>\n<p>In the offset layout, moving to the upper-right neighbor from a hexagon depends on whether the current row is even or odd. In an even row, the upper-right neighbor is in the same column. In an odd row, it shifts one column to the right.</p>\n<p>The final code encodes this as two lookup tables:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>const</span><span> int8_t</span><span> neighbors_set</span><span>[</span><span>2</span><span>][</span><span>6</span><span>][</span><span>2</span><span>] </span><span>=</span><span> {</span></span>\n<span class=\"line\"><span>    // Even rows (parity 0): (col_offset, row_offset)</span></span>\n<span class=\"line\"><span>    { {</span><span>-</span><span>1</span><span>,</span><span>+</span><span>1</span><span>}, {</span><span>0</span><span>,</span><span>+</span><span>1</span><span>}, {</span><span>+</span><span>1</span><span>,</span><span>0</span><span>}, {</span><span>0</span><span>,</span><span>-</span><span>1</span><span>}, {</span><span>-</span><span>1</span><span>,</span><span>-</span><span>1</span><span>}, {</span><span>-</span><span>1</span><span>,</span><span>0</span><span>} },</span></span>\n<span class=\"line\"><span>    // Odd rows (parity 1):</span></span>\n<span class=\"line\"><span>    { {</span><span>0</span><span>,</span><span>+</span><span>1</span><span>}, {</span><span>+</span><span>1</span><span>,</span><span>+</span><span>1</span><span>}, {</span><span>+</span><span>1</span><span>,</span><span>0</span><span>}, {</span><span>+</span><span>1</span><span>,</span><span>-</span><span>1</span><span>}, {</span><span>0</span><span>,</span><span>-</span><span>1</span><span>}, {</span><span>-</span><span>1</span><span>,</span><span>0</span><span>} }</span></span>\n<span class=\"line\"><span>};</span></span></code></pre>\n<p>During Dijkstra, the row parity of the current node selects the correct table, and each of the six neighbors is generated by adding the corresponding offset to the current <code>(col, row)</code> and checking bounds with <code>isinrange</code>. No adjacency list is needed.</p>\n<h3><strong>4.3 Cube Coordinates and Hex Distance</strong></h3>\n<p>Hex distance is the core quantity behind <code>change_cost</code> and the geometric model. Computing it directly in offset coordinates is error-prone because the parity-dependent shifts make the arithmetic inconsistent across rows. Cube coordinates solve this.</p>\n<p>In cube coordinates, every hexagon is represented as <code class=\"formula-inline\">(q, r, s)</code> with the constraint <code class=\"formula-inline\">q + r + s = 0</code>. The key property: each single hex step changes exactly two of the three coordinates by <code class=\"formula-inline\">\\pm 1</code> — one increases, one decreases, the third stays the same — so the constraint is always maintained. This means each step adds exactly 2 to the Manhattan distance <code class=\"formula-inline\">|{\\Delta q}| + |{\\Delta r}| + |{\\Delta s}|</code>. Therefore:</p>\n<div class=\"formula\"><code>d(a, b) = \\frac{|q_1 - q_2| + |r_1 - r_2| + |s_1 - s_2|}{2}</code></div>\n<p>This formula is parity-independent and well-defined regardless of the offset convention, which is the whole point of converting to cube coordinates before computing distance.</p>\n<h3><strong>4.4 Offset-to-Cube Conversion and the <code>ROWS % 2</code> Branch</strong></h3>\n<p>The conversion from offset coordinates to cube depends on whether the layout is <strong>odd-r</strong> (odd rows shifted right) or <strong>even-r</strong> (even rows shifted right). The formulas from the Red Blob Games model are:</p>\n<ul>\n<li><strong>odd-r</strong>: <code class=\"formula-inline\">q = \\text{col} - \\lfloor(\\text{row} - \\text{parity}) / 2\\rfloor</code>,   <code class=\"formula-inline\">r = \\text{row}</code>,   <code class=\"formula-inline\">s = -q - r</code></li>\n<li><strong>even-r</strong>: <code class=\"formula-inline\">q = \\text{col} - \\lfloor(\\text{row} + \\text{parity}) / 2\\rfloor</code>,   <code class=\"formula-inline\">r = \\text{row}</code>,   <code class=\"formula-inline\">s = -q - r</code></li>\n</ul>\n<p>where <code class=\"formula-inline\">\\text{parity} = \\text{row} \\bmod 2</code>. The only difference between the two is a single sign flip in the <code class=\"formula-inline\">q</code> computation: <code>+parity</code> for even-r, <code>-parity</code> for odd-r. Getting that sign wrong produces plausible-looking results that silently fail on specific row parities.</p>\n<p>Now the complication. The specification numbers rows from the bottom. The cube-coordinate formulas assume top-based numbering. Before converting, the code flips the row:</p>\n<div class=\"formula\"><code>y' = (\\text{ROWS} - 1) - y</code></div>\n<p>This flip inverts parity when <code class=\"formula-inline\">\\text{ROWS} - 1</code> is odd (i.e., when <code>ROWS</code> is even):</p>\n<div class=\"formula\"><code>\\operatorname{parity}(y') = \\operatorname{parity}(\\text{ROWS} - 1) \\oplus \\operatorname{parity}(y)</code></div>\n<p>The consequence:</p>\n<ul>\n<li>if <code>ROWS</code> is <strong>odd</strong>, <code class=\"formula-inline\">\\text{ROWS} - 1</code> is even, parity is preserved → the grid remains <strong>odd-r</strong> after flipping → use the odd-r formula (<code class=\"formula-inline\">-\\text{parity}</code>)</li>\n<li>if <code>ROWS</code> is <strong>even</strong>, <code class=\"formula-inline\">\\text{ROWS} - 1</code> is odd, parity inverts → the grid becomes <strong>even-r</strong> after flipping → use the even-r formula (<code class=\"formula-inline\">+\\text{parity}</code>)</li>\n</ul>\n<p>That is the real reason the final code branches on <code>ROWS % 2</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>if</span><span>(ROWS </span><span>%</span><span> 2</span><span> ==</span><span> 0</span><span>){</span></span>\n<span class=\"line\"><span>    i1 </span><span>=</span><span> (ROWS </span><span>-</span><span> 1</span><span>) </span><span>-</span><span> i1;</span></span>\n<span class=\"line\"><span>    i2 </span><span>=</span><span> (ROWS </span><span>-</span><span> 1</span><span>) </span><span>-</span><span> i2;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    int8_t</span><span> parity1 </span><span>=</span><span> i1 </span><span>%</span><span> 2</span><span>;</span></span>\n<span class=\"line\"><span>    int8_t</span><span> parity2 </span><span>=</span><span> i2 </span><span>%</span><span> 2</span><span>;</span></span>\n<span class=\"line\"><span>    int32_t</span><span> q1 </span><span>=</span><span> j1 </span><span>-</span><span> ((i1 </span><span>+</span><span> parity1)</span><span>/</span><span>2</span><span>);</span><span>   // even-r</span></span>\n<span class=\"line\"><span>    int32_t</span><span> q2 </span><span>=</span><span> j2 </span><span>-</span><span> ((i2 </span><span>+</span><span> parity2)</span><span>/</span><span>2</span><span>);</span></span>\n<span class=\"line\"><span>    ...</span></span>\n<span class=\"line\"><span>}</span><span>else</span><span>{</span></span>\n<span class=\"line\"><span>    i1 </span><span>=</span><span> (ROWS </span><span>-</span><span> 1</span><span>) </span><span>-</span><span> i1;</span></span>\n<span class=\"line\"><span>    i2 </span><span>=</span><span> (ROWS </span><span>-</span><span> 1</span><span>) </span><span>-</span><span> i2;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    int8_t</span><span> parity1 </span><span>=</span><span> i1 </span><span>%</span><span> 2</span><span>;</span></span>\n<span class=\"line\"><span>    int8_t</span><span> parity2 </span><span>=</span><span> i2 </span><span>%</span><span> 2</span><span>;</span></span>\n<span class=\"line\"><span>    int32_t</span><span> q1 </span><span>=</span><span> j1 </span><span>-</span><span> ((i1 </span><span>-</span><span> parity1)</span><span>/</span><span>2</span><span>);</span><span>   // odd-r</span></span>\n<span class=\"line\"><span>    int32_t</span><span> q2 </span><span>=</span><span> j2 </span><span>-</span><span> ((i2 </span><span>-</span><span> parity2)</span><span>/</span><span>2</span><span>);</span></span>\n<span class=\"line\"><span>    ...</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>Both branches then compute <code class=\"formula-inline\">s = -q - r</code> and return <code class=\"formula-inline\">(|q_1-q_2|+|r_1-r_2|+|s_1-s_2|)/2</code>.</p>\n<p>If the even-r/odd-r choice is inverted, or if the row flip is omitted, the distance function produces results that look correct on many test cases but fail systematically on whole classes of inputs where the row parity changes the expected geometry.</p>\n<hr />\n<h2><strong>5. The Invariant That Simplifies Air Routes</strong></h2>\n<p>The final representation stores no per-route cost. Each air route is just a destination index. This is possible because of an invariant that holds throughout execution.</p>\n<p><strong>Invariant</strong>: all outgoing air routes from the same source <code class=\"formula-inline\">u</code> always have the same cost, and that cost equals <code class=\"formula-inline\">\\operatorname{weight}[u]</code>.</p>\n<p><strong>Proof by induction on the sequence of operations:</strong></p>\n<ol>\n<li><strong>First air route from <code class=\"formula-inline\">u</code></strong>: there are no existing air routes. The cost is <code class=\"formula-inline\">\\lfloor \\operatorname{weight}[u] / 1 \\rfloor = \\operatorname{weight}[u]</code>.</li>\n<li><strong>Inductive step</strong> (<code>toggle_air_route</code>): suppose all <code class=\"formula-inline\">k</code> existing outgoing air routes from <code class=\"formula-inline\">u</code> have cost <code class=\"formula-inline\">\\operatorname{weight}[u]</code>. The new route's cost is <code class=\"formula-inline\">\\lfloor (k \\cdot \\operatorname{weight}[u] + \\operatorname{weight}[u]) / (k + 1) \\rfloor = \\operatorname{weight}[u]</code>.</li>\n<li><strong>Preservation through <code>change_cost</code></strong>: the specification says the same update formula applies both to <code class=\"formula-inline\">\\operatorname{weight}[u]</code> and to all outgoing air routes of <code class=\"formula-inline\">u</code>. The delta <code class=\"formula-inline\">\\Delta(d) = \\lfloor v \\cdot \\max(0, (r - d)/r) \\rfloor</code> depends only on the distance from the pivot and the parameters <code class=\"formula-inline\">v</code> and <code class=\"formula-inline\">r</code> — not on the current cost. So weight and every air-route cost receive the same integer delta. After clamping to <code class=\"formula-inline\">[0,100]</code>, they remain equal.</li>\n</ol>\n<p>The consequence: the effective traversal cost of an air move from <code class=\"formula-inline\">u</code> is always <code class=\"formula-inline\">\\operatorname{weight}[u]</code>, which is the same as the ground exit cost. The code stores only destination indices:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>air_route</span><span>[index1][</span><span>*</span><span>counter_air_route] </span><span>=</span><span> index2;</span></span>\n<span class=\"line\"><span>*</span><span>counter_air_route </span><span>+=</span><span> 1</span><span>;</span></span></code></pre>\n<p>And the shortest-path code uses <code>currweight</code> for both ground and air moves:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>// Ground links</span></span>\n<span class=\"line\"><span>new_distance </span><span>=</span><span> currdistance </span><span>+</span><span> currweight;</span></span>\n<span class=\"line\"><span>...</span></span>\n<span class=\"line\"><span>// Air links</span></span>\n<span class=\"line\"><span>uint32_t</span><span> neighbor </span><span>=</span><span> air_route</span><span>[i];</span></span>\n<span class=\"line\"><span>new_distance </span><span>=</span><span> currdistance </span><span>+</span><span> currweight;</span></span></code></pre>\n<p>This invariant also simplifies <code>change_cost</code>: since air-route costs are implicit in <code>weight[]</code>, the function only needs to update the weight array. The air-route cost update mandated by the specification is automatically satisfied.</p>\n<hr />\n<h2><strong>6. <code>change_cost</code> in the Final Implementation</strong></h2>\n<p><code>change_cost</code> is a geometric enumeration problem, not a traversal problem.</p>\n<p>An earlier implementation used a queue-based expansion with a visited array — start from the pivot, expand to neighbors, track distance, stop at the radius. It was correct, but profiling showed <code>insert_neighbors</code> was a major bottleneck: expanding neighbors iteratively through linked lists was expensive, especially for large radii.</p>\n<p>The final implementation starts from geometry instead. Once the pivot has been converted into cube coordinates, the code iterates directly over all cube offsets inside the hexagonal region:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>for</span><span> (</span><span>int32_t</span><span> q2 </span><span>=</span><span> -</span><span>radius; q2 </span><span>&lt;=</span><span> radius; q2</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>    int32_t</span><span> r2_start </span><span>=</span><span> max</span><span>(</span><span>-</span><span>radius, </span><span>-</span><span>q2 </span><span>-</span><span> radius);</span></span>\n<span class=\"line\"><span>    int32_t</span><span> r2_end </span><span>=</span><span> min</span><span>(radius, </span><span>-</span><span>q2 </span><span>+</span><span> radius);</span></span>\n<span class=\"line\"><span>    for</span><span> (</span><span>int32_t</span><span> r2 </span><span>=</span><span> r2_start; r2 </span><span>&lt;=</span><span> r2_end; r2</span><span>++</span><span>) {</span></span>\n<span class=\"line\"><span>        int32_t</span><span> qc </span><span>=</span><span> q1 </span><span>+</span><span> q2;</span></span>\n<span class=\"line\"><span>        int32_t</span><span> rc </span><span>=</span><span> r1 </span><span>+</span><span> r2;</span></span>\n<span class=\"line\"><span>        ...</span></span>\n<span class=\"line\"><span>        uint32_t</span><span> distance </span><span>=</span><span> hex_distance</span><span>(j1, i1, j2, i2, ROWS);</span></span>\n<span class=\"line\"><span>        if</span><span>(distance </span><span>&gt;=</span><span> (</span><span>uint32_t</span><span>)radius) </span><span>continue</span><span>;</span></span>\n<span class=\"line\"><span>        change_cost_hex</span><span>(weight, index, distance, v, radius);</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The nested loop directly enumerates all cube coordinates <code class=\"formula-inline\">(q_c, r_c, s_c)</code> such that <code class=\"formula-inline\">|q_2| + |r_2| + |s_2| \\le 2 \\cdot \\text{radius}</code> (the hexagonal region around the pivot). The <code>r2_start</code>/<code>r2_end</code> bounds enforce the cube constraint <code class=\"formula-inline\">q + r + s = 0</code> implicitly — they restrict <code class=\"formula-inline\">r_2</code> so that <code class=\"formula-inline\">s_2 = -q_2 - r_2</code> stays within <code class=\"formula-inline\">[-\\text{radius}, +\\text{radius}]</code>.</p>\n<p><img src=\"https://www.tommasovaccari.com/static/change-cost-radius-example-7c69f51c.webp\" alt=\"Hex-distance based cost update\" /></p>\n<p><em>Hexagons at the same hex distance from the center receive the same update delta, so the effect propagates by hex-distance layers.</em></p>\n<p>Each candidate is converted back to grid coordinates, bounds-checked, and then updated with the weighted delta:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>int32_t</span><span> compute_weighted_value</span><span>(</span><span>float</span><span> v</span><span>, </span><span>float</span><span> radius</span><span>, </span><span>float</span><span> distance</span><span>) {</span></span>\n<span class=\"line\"><span>    float</span><span> normalized </span><span>=</span><span> (radius </span><span>-</span><span> distance) </span><span>/</span><span> radius;</span></span>\n<span class=\"line\"><span>    float</span><span> clamped </span><span>=</span><span> fmaxf</span><span>(</span><span>0.0</span><span>f</span><span>, normalized);</span></span>\n<span class=\"line\"><span>    float</span><span> weighted </span><span>=</span><span> v </span><span>*</span><span> clamped;</span></span>\n<span class=\"line\"><span>    return</span><span> (</span><span>int32_t</span><span>)</span><span>floorf</span><span>(weighted);</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The update clamps the result to <code class=\"formula-inline\">[0,100]</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>void</span><span> change_cost_hex</span><span>(</span><span>uint8_t</span><span> *</span><span>weight</span><span>, </span><span>uint32_t</span><span> index</span><span>, </span><span>uint32_t</span><span> distance</span><span>,</span></span>\n<span class=\"line\"><span>                     int8_t</span><span> v</span><span>, </span><span>int32_t</span><span> radius</span><span>)</span></span>\n<span class=\"line\"><span>{</span></span>\n<span class=\"line\"><span>    int32_t</span><span> new_weight </span><span>=</span><span> compute_weighted_value</span><span>(v, radius, distance);</span></span>\n<span class=\"line\"><span>    int32_t</span><span> sum </span><span>=</span><span> weight</span><span>[index] </span><span>+</span><span> new_weight;</span></span>\n<span class=\"line\"><span>    if</span><span>(sum </span><span>&lt;</span><span> 0</span><span>) sum </span><span>=</span><span> 0</span><span>;</span></span>\n<span class=\"line\"><span>    if</span><span>(sum </span><span>&gt;</span><span> 100</span><span>) sum </span><span>=</span><span> 100</span><span>;</span></span>\n<span class=\"line\"><span>    weight</span><span>[index] </span><span>=</span><span> (</span><span>uint8_t</span><span>) sum;</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<hr />\n<h2><strong>7. <code>travel_cost</code> and the Dijkstra Implementations</strong></h2>\n<h3><strong>7.1 The Graph Model</strong></h3>\n<p>At the modeling level, the problem is a sparse directed graph with nonnegative integer edge weights:</p>\n<ul>\n<li>each hexagon is a node</li>\n<li>ground adjacency contributes up to 6 outgoing edges, derived on the fly from coordinates and row parity</li>\n<li>air routes contribute up to 5 additional outgoing edges, read from <code>air_route[]</code></li>\n<li>all effective edge weights equal <code>weight[source]</code> (from the invariant in Section 5)</li>\n<li>weights are bounded integers in <code class=\"formula-inline\">[0, 100]</code></li>\n</ul>\n<p>All edge weights are nonnegative, so Dijkstra's algorithm is the correct base choice. The final code keeps two implementations behind the compile-time flag <code>DIJKSTRA_IMPL</code>: <code>0</code> for binary min-heap, <code>1</code> for weight-sorted buckets. The default build uses the second.</p>\n<h3><strong>7.2 Common Structure</strong></h3>\n<p>Both Dijkstra variants share the same relaxation logic. For the currently visited node, the code computes its row parity via <code>convert1to2</code>, then iterates over all 6 ground neighbors (using <code>neighbors_set[parity]</code>) and all air-route destinations. For each reachable neighbor:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>// Ground links</span></span>\n<span class=\"line\"><span>for</span><span>(</span><span>int8_t</span><span> i </span><span>=</span><span> 0</span><span>; i </span><span>&lt;</span><span> 6</span><span>; i</span><span>++</span><span>){</span></span>\n<span class=\"line\"><span>    int32_t</span><span> sx2 </span><span>=</span><span> (</span><span>int32_t</span><span>)x1 </span><span>+</span><span> neighbors_set</span><span>[parity][i][</span><span>0</span><span>];</span></span>\n<span class=\"line\"><span>    int32_t</span><span> sy2 </span><span>=</span><span> (</span><span>int32_t</span><span>)y1 </span><span>+</span><span> neighbors_set</span><span>[parity][i][</span><span>1</span><span>];</span></span>\n<span class=\"line\"><span>    if</span><span>(</span><span>!</span><span>isinrange</span><span>(sy2, sx2, ROWS, COLS)) </span><span>continue</span><span>;</span></span>\n<span class=\"line\"><span>    ...</span></span>\n<span class=\"line\"><span>    new_distance </span><span>=</span><span> currdistance </span><span>+</span><span> currweight;</span></span>\n<span class=\"line\"><span>    if</span><span>(new_distance </span><span>&lt;</span><span> distance</span><span>[neighbor_index]){</span></span>\n<span class=\"line\"><span>        // relax</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"><span>// Air links</span></span>\n<span class=\"line\"><span>for</span><span>(</span><span>int8_t</span><span> i </span><span>=</span><span> 0</span><span>; i </span><span>&lt;</span><span> counter_air_route</span><span>[hexindex_visiting]; i</span><span>++</span><span>){</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> neighbor </span><span>=</span><span> air_route</span><span>[i];</span></span>\n<span class=\"line\"><span>    new_distance </span><span>=</span><span> currdistance </span><span>+</span><span> currweight;</span></span>\n<span class=\"line\"><span>    if</span><span>(new_distance </span><span>&lt;</span><span> distance</span><span>[neighbor]){</span></span>\n<span class=\"line\"><span>        // relax</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>Both ground and air relaxation use <code>currdistance + currweight</code> as the candidate distance. The invariant from Section 5 is what makes this correct for air routes: the air-route traversal cost is always equal to the source node's weight.</p>\n<p>Both variants handle <code>weight == 0</code> the same way: a hexagon with zero weight can be <strong>visited</strong> (its distance is set during relaxation from a neighbor) but it cannot be <strong>departed from</strong> — its neighbors are never relaxed through it. This directly implements the specification rule that a zero-cost hexagon \"cannot be abandoned but can be visited.\"</p>\n<p>Both variants use <strong>early exit</strong>: once the destination is extracted from the priority structure, the algorithm terminates. Dijkstra's optimality guarantee ensures that at that point its distance is final, so there is no need to continue.</p>\n<p>Both variants also handle <code>travel_cost</code> bookkeeping the same way:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>if</span><span>((i1 </span><span>==</span><span> i2) </span><span>&amp;&amp;</span><span> (j1 </span><span>==</span><span> j2)){</span></span>\n<span class=\"line\"><span>    *</span><span>COST </span><span>=</span><span> 0</span><span>;</span></span>\n<span class=\"line\"><span>    return</span><span> 1</span><span>;</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"><span>...</span></span>\n<span class=\"line\"><span>if</span><span>(</span><span>find_cost</span><span>(hash_table, index1, index2, COST) </span><span>==</span><span> 1</span><span>){</span></span>\n<span class=\"line\"><span>    if</span><span>(</span><span>*</span><span>COST </span><span>==</span><span> UINT32_MAX) </span><span>return</span><span> -</span><span>1</span><span>;</span></span>\n<span class=\"line\"><span>    return</span><span> 1</span><span>;</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"><span>...</span></span>\n<span class=\"line\"><span>// Run Dijkstra</span></span>\n<span class=\"line\"><span>*</span><span>COST </span><span>=</span><span> distance</span><span>[index2];</span></span>\n<span class=\"line\"><span>insert_cost</span><span>(hash_table, index1, index2, COST);</span></span>\n<span class=\"line\"><span>if</span><span>(</span><span>distance</span><span>[index2] </span><span>==</span><span> UINT32_MAX) </span><span>return</span><span> -</span><span>1</span><span>;</span></span>\n<span class=\"line\"><span>return</span><span> 1</span><span>;</span></span></code></pre>\n<p>The source-equals-destination check happens before anything else. Then the cache is queried. Only if both miss does Dijkstra run. After the run, the result is cached and returned — including <code>UINT32_MAX</code> for unreachable destinations, so repeated misses also avoid recomputation.</p>\n<h3><strong>7.3 Binary Min-Heap Variant (<code>DIJKSTRA_IMPL 0</code>)</strong></h3>\n<p>The heap variant uses a standard binary min-heap with an explicit position-tracking array.</p>\n<p>Each heap entry is a <code>heap_block_t</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>typedef</span><span> struct</span><span> heap{</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> distance;</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> hexnumber;</span></span>\n<span class=\"line\"><span>}</span><span>heap_block_t</span><span>;</span></span></code></pre>\n<p>The implementation detail that makes this work is <code>min_heap_index[]</code> (<code>int32_t</code> per node), which tracks every node's state with respect to the heap using <strong>three distinct values</strong>:</p>\n<ul>\n<li><strong><code>-1</code></strong> (initialized via <code>memset(0xFF)</code>): the node has never been inserted into the heap</li>\n<li><strong>valid index</strong> (≥ 0): the node is currently in the heap at that position</li>\n<li><strong><code>INT32_MAX</code></strong>: the node has been popped and must not be reinserted (settled)</li>\n</ul>\n<p>This three-state tracking is what allows <code>relax_neighbor</code> to distinguish between insertion and decrease-key without a separate settled array:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>static</span><span> inline</span><span> void</span><span> relax_neighbor</span><span>(</span><span>heap_block_t</span><span> *</span><span>min_heap</span><span>, </span><span>uint32_t</span><span> *</span><span>distance</span><span>,</span></span>\n<span class=\"line\"><span>    int32_t</span><span> *</span><span>min_heap_index</span><span>, </span><span>uint32_t</span><span> *</span><span>heap_size</span><span>,</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> neighbor_index</span><span>, </span><span>uint32_t</span><span> new_distance</span><span>)</span></span>\n<span class=\"line\"><span>{</span></span>\n<span class=\"line\"><span>    if</span><span>(</span><span>min_heap_index</span><span>[neighbor_index] </span><span>==</span><span> -</span><span>1</span><span>){</span></span>\n<span class=\"line\"><span>        // Never seen → insert into heap</span></span>\n<span class=\"line\"><span>        distance</span><span>[neighbor_index] </span><span>=</span><span> new_distance;</span></span>\n<span class=\"line\"><span>        min_heap</span><span>[</span><span>*</span><span>heap_size].distance </span><span>=</span><span> new_distance;</span></span>\n<span class=\"line\"><span>        min_heap</span><span>[</span><span>*</span><span>heap_size].hexnumber </span><span>=</span><span> neighbor_index;</span></span>\n<span class=\"line\"><span>        min_heap_index</span><span>[neighbor_index] </span><span>=</span><span> *</span><span>heap_size;</span></span>\n<span class=\"line\"><span>        *</span><span>heap_size </span><span>+=</span><span> 1</span><span>;</span></span>\n<span class=\"line\"><span>        bubble_up</span><span>(min_heap, </span><span>min_heap_index</span><span>[neighbor_index], min_heap_index);</span></span>\n<span class=\"line\"><span>    }</span><span>else</span><span> if</span><span>(</span><span>min_heap_index</span><span>[neighbor_index] </span><span>!=</span><span> INT32_MAX){</span></span>\n<span class=\"line\"><span>        // Currently in heap → decrease-key</span></span>\n<span class=\"line\"><span>        distance</span><span>[neighbor_index] </span><span>=</span><span> new_distance;</span></span>\n<span class=\"line\"><span>        min_heap</span><span>[</span><span>min_heap_index</span><span>[neighbor_index]].distance </span><span>=</span><span> new_distance;</span></span>\n<span class=\"line\"><span>        bubble_up</span><span>(min_heap, </span><span>min_heap_index</span><span>[neighbor_index], min_heap_index);</span></span>\n<span class=\"line\"><span>    }</span></span>\n<span class=\"line\"><span>    // If INT32_MAX → already settled, skip</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p><code>pop_min</code> extracts the root, moves the last heap element to position 0, calls <code>min_heapify</code> to restore the heap property, and marks the popped node as <code>INT32_MAX</code> in <code>min_heap_index</code>. Both <code>bubble_up</code> and <code>min_heapify</code> update <code>min_heap_index</code> on every swap, so the mapping stays consistent.</p>\n<p>The initialization allocates the heap and index arrays once in <code>main()</code> after <code>init</code>, and each Dijkstra run resets them with <code>memset</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>memset</span><span>(distance, </span><span>0x</span><span>FF</span><span>, ROWS</span><span>*</span><span>COLS </span><span>*</span><span> sizeof</span><span>(</span><span>uint32_t</span><span>));</span></span>\n<span class=\"line\"><span>memset</span><span>(min_heap_index, </span><span>0x</span><span>FF</span><span>, (ROWS</span><span>*</span><span>COLS)</span><span>*sizeof</span><span>(</span><span>int32_t</span><span>));</span></span></code></pre>\n<p>The complexity is <code class=\"formula-inline\">O((V + E) \\log V)</code>. Since each hexagon has at most 11 outgoing edges (6 ground + 5 air), <code class=\"formula-inline\">E = O(V)</code>, so the total is <code class=\"formula-inline\">O(V \\log V)</code>.</p>\n<h3><strong>7.4 Weight-Sorted Bucket Variant (<code>DIJKSTRA_IMPL 1</code>)</strong></h3>\n<p>The bucket variant exploits the fact that edge weights are bounded integers in <code class=\"formula-inline\">[0, 100]</code>.</p>\n<p>The idea comes from <strong>Dial's algorithm</strong>: instead of a heap, use a circular array of <strong>101 buckets</strong> indexed by <code>distance % 101</code>. Since the maximum single-edge weight is 100, all active (unprocessed) entries fit within a window of 101 consecutive distance values at any point during execution. Scanning buckets in order from the current index guarantees that entries are processed in nondecreasing distance order.</p>\n<p>Each bucket is a <code>buckets_t</code>:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>typedef</span><span> struct</span><span> buck{</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> *</span><span>nodes;</span><span>  // node indices</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> *</span><span>keys;</span><span>   // distances at insertion time</span></span>\n<span class=\"line\"><span>    int32_t</span><span> size;</span><span>     // total entries pushed</span></span>\n<span class=\"line\"><span>    int32_t</span><span> cursor;</span><span>   // next entry to pop</span></span>\n<span class=\"line\"><span>    int32_t</span><span> maxdim;</span><span>   // current allocation capacity</span></span>\n<span class=\"line\"><span>}</span><span>buckets_t</span><span>;</span></span></code></pre>\n<p>Each bucket stores two parallel arrays — <code>nodes[]</code> and <code>keys[]</code> — instead of an array of structs. This was a deliberate choice driven by the same principle that led to dismantling <code>hex_t</code>: keeping related data types contiguous for better cache behavior during sequential scans.</p>\n<p>The relaxation function is much simpler than the heap variant — it just pushes a new entry into the appropriate bucket:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>static</span><span> inline</span><span> void</span><span> relax_neighbor_weight_sort</span><span>(</span><span>buckets_t</span><span> *</span><span>bucket</span><span>, </span><span>uint32_t</span><span> key</span><span>,</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> hexindex</span><span>, </span><span>uint32_t</span><span> *</span><span>tobeprocessed</span><span>, </span><span>uint32_t</span><span> *</span><span>distance</span><span>)</span></span>\n<span class=\"line\"><span>{</span></span>\n<span class=\"line\"><span>    distance</span><span>[hexindex] </span><span>=</span><span> key;</span></span>\n<span class=\"line\"><span>    push</span><span>(bucket, hexindex, key, tobeprocessed);</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The caller selects the bucket as <code>buckets[new_distance % 101]</code>. This means the same node can appear in multiple buckets with different distances (a lazy approach). The algorithm handles this with <strong>stale-entry detection</strong> at pop time:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>pop</span><span>(</span><span>&amp;</span><span>buckets</span><span>[idx], </span><span>&amp;</span><span>tobeprocessed</span><span>, </span><span>&amp;</span><span>hexindex_visiting</span><span>, </span><span>&amp;</span><span>key</span><span>);</span></span>\n<span class=\"line\"><span>if</span><span>(hexindex_visiting </span><span>==</span><span> UINT32_MAX){</span></span>\n<span class=\"line\"><span>    idx </span><span>=</span><span> (idx</span><span>+</span><span>1</span><span>) </span><span>%</span><span> 101</span><span>;</span></span>\n<span class=\"line\"><span>    continue</span><span>;</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"><span>...</span></span>\n<span class=\"line\"><span>if</span><span> (key </span><span>!=</span><span> currdistance)       </span><span>continue</span><span>;</span><span>  // stale entry — distance was improved since insertion</span></span>\n<span class=\"line\"><span>if</span><span> (</span><span>settled</span><span>[hexindex_visiting]) </span><span>continue</span><span>;</span><span>  // already finalized</span></span>\n<span class=\"line\"><span>if</span><span> (hexindex_visiting </span><span>==</span><span> DST)  </span><span>break</span><span>;</span><span>     // early exit — even if weight is 0</span></span>\n<span class=\"line\"><span>if</span><span> (currweight </span><span>==</span><span> 0</span><span>)           </span><span>continue</span><span>;</span><span>  // cannot depart from this node</span></span></code></pre>\n<p>The stale-entry check (<code>key != currdistance</code>) is necessary because a node's best distance may have been improved after this entry was pushed — the old entry remains in the bucket but is no longer valid. The <code>settled[]</code> array (<code>uint8_t</code> per node, zeroed at the start of each run) tracks finalized nodes. The early-exit check comes before the weight check: the destination's distance is already final when it is popped, and its exit cost is ignored by the specification, so there is no reason to continue regardless of its weight.</p>\n<p>The bucket scanning loop advances <code>idx = (idx+1) % 101</code> whenever a bucket is exhausted, cycling through the circular array. The <code>push</code> operation doubles the bucket's allocation when full (<code>realloc</code> to <code>2 * maxdim</code>). The <code>pop</code> operation resets <code>cursor</code> and <code>size</code> to 0 when the cursor catches up to size, reclaiming the bucket for reuse.</p>\n<p>The amortized complexity is <code class=\"formula-inline\">O(V + E + C \\cdot V)</code> where <code class=\"formula-inline\">C = 101</code>. Since <code class=\"formula-inline\">E = O(V)</code> and <code class=\"formula-inline\">C</code> is a constant, this simplifies to <code class=\"formula-inline\">O(V)</code> for this problem — eliminating the <code class=\"formula-inline\">\\log V</code> factor from heap operations.</p>\n<h3><strong>7.5 Measured Results</strong></h3>\n<ul>\n<li><strong>Binary-heap Dijkstra</strong>: 8.5 s, 9.6 MiB</li>\n<li><strong>Weight-sorted bucket Dijkstra</strong>: 4.4 s, 9.0 MiB</li>\n</ul>\n<p>Both satisfy the 30L thresholds. The bucket variant nearly halves the execution time because it replaces <code class=\"formula-inline\">O(\\log V)</code> heap operations with <code class=\"formula-inline\">O(1)</code> bucket insertions and amortized scanning.</p>\n<hr />\n<h2><strong>8. Caching Repeated Queries</strong></h2>\n<p>The specification hints that <code>travel_cost</code> will be called frequently and that queries concentrate in the same zones. That made caching worth implementing.</p>\n<p>The final strategy is strict:</p>\n<ul>\n<li>exact memoization on <code>(source, destination)</code></li>\n<li>hash-table lookup before running Dijkstra</li>\n<li>full cache invalidation after every successful <code>init</code>, <code>change_cost</code>, or <code>toggle_air_route</code></li>\n<li>unreachable destinations cached as <code>UINT32_MAX</code>, so repeated misses also avoid recomputation</li>\n</ul>\n<p>There is no partial invalidation. The graph is dynamic and correctness is easy to lose with selective invalidation schemes. Full flush on mutation is simple and trustworthy.</p>\n<p>The cache is a <strong>256-bucket hash table</strong> with separate chaining. The key is the ordered pair <code>(src, dst)</code>: first I combine the two indices with a mixing function using large primes, then I hash the result using <strong>Knuth's multiplicative method</strong> and take the top 8 bits as the bucket index:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>uint32_t</span><span> combine</span><span>(</span><span>uint32_t</span><span> src</span><span>, </span><span>uint32_t</span><span> dst</span><span>){</span></span>\n<span class=\"line\"><span>    return</span><span> src </span><span>*</span><span> 73856093</span><span>u</span><span> ^</span><span> dst </span><span>*</span><span> 19349663</span><span>u</span><span>;</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>static</span><span> inline</span><span> uint32_t</span><span> hash</span><span>(</span><span>uint32_t</span><span> k</span><span>) {</span></span>\n<span class=\"line\"><span>    uint64_t</span><span> prod </span><span>=</span><span> (</span><span>uint64_t</span><span>)k </span><span>*</span><span> ALPHA_FIXEDPOINT;</span></span>\n<span class=\"line\"><span>    uint32_t</span><span> frac </span><span>=</span><span> (</span><span>uint32_t</span><span>)prod;</span></span>\n<span class=\"line\"><span>    return</span><span> frac </span><span>&gt;&gt;</span><span> 24</span><span>;</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p><code>ALPHA_FIXEDPOINT</code> is <code>2654435769u</code>, the fixed-point representation of Knuth's suggested constant <code class=\"formula-inline\">(\\sqrt{5}-1)/2</code>. The shift by 24 bits produces an 8-bit index in <code class=\"formula-inline\">[0, 255]</code>.</p>\n<p>Invalidation after a successful mutation flushes the entire table:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>if</span><span>(</span><span>change_cost</span><span>(weight, args, ROWS, COLS) </span><span>==</span><span> 1</span><span>){</span></span>\n<span class=\"line\"><span>    printf</span><span>(</span><span>\"OK</span><span>\\n</span><span>\"</span><span>);</span></span>\n<span class=\"line\"><span>    free_hash_table</span><span>(hash_table);</span></span>\n<span class=\"line\"><span>}</span></span></code></pre>\n<p>The same pattern is used after <code>init</code> and <code>toggle_air_route</code>.</p>\n<hr />\n<h2><strong>9. Profiling and the Optimization Sequence</strong></h2>\n<p>Once the code was correct, profiling determined the optimization path. This was not guesswork — I used <code>xctrace</code> (Time Profiler) and Cachegrind at multiple stages.</p>\n<p>One Cachegrind snapshot from an earlier version showed:</p>\n<table><thead><tr><th>Function</th><th>Instruction Share</th></tr></thead><tbody><tr><td><code>min_heapify</code></td><td>31.78%</td></tr><tr><td><code>Dijkstra</code></td><td>21.12%</td></tr><tr><td><code>pop_min</code></td><td>14.32%</td></tr><tr><td><code>insert_neighbors</code></td><td>9.85%</td></tr><tr><td><code>hex_distance</code></td><td>6.86%</td></tr><tr><td><code>bubble_up</code></td><td>5.65%</td></tr><tr><td><code>change_cost</code></td><td>3.86%</td></tr></tbody></table>\n<p>These bottlenecks were not all of the same kind:</p>\n<ul>\n<li><strong>Heap maintenance</strong> (<code>min_heapify</code> + <code>pop_min</code> + <code>bubble_up</code> totaling ~52%) dominated inside repeated shortest-path queries. The <code class=\"formula-inline\">\\log V</code> cost per operation was being paid on every node extraction and every decrease-key.</li>\n<li><strong>Neighbor expansion</strong> (<code>insert_neighbors</code> at ~10%) was expensive in the traversal-based <code>change_cost</code> — it was walking linked lists to find neighbors instead of computing them directly.</li>\n<li><strong>Pointer-heavy structures</strong> (the original <code>hex_t</code> struct with embedded linked lists for ground links and air routes) were hurting cache locality. The <code>_int_malloc</code> and <code>_int_free</code> entries in the full Cachegrind output confirmed that dynamic allocation overhead was nontrivial.</li>\n</ul>\n<p>That breakdown pushed a specific sequence of redesigns:</p>\n<ol>\n<li><strong><code>change_cost</code>: traversal to geometric enumeration.</strong> The queue-based expansion with visited arrays was replaced with the direct cube-offset iteration shown in Section 6. The traversal version spent most of its time in <code>insert_neighbors</code>; the geometric version eliminated that function entirely.</li>\n<li><strong>Linked lists to arrays.</strong> Ground links and air routes were moved from linked lists to compact arrays. Sequential array access is fundamentally faster than pointer chasing through scattered heap-allocated nodes. The Cachegrind data confirmed that <code>_int_malloc</code> and <code>_int_free</code> were contributing nontrivial overhead from the linked-list allocations.</li>\n<li><strong>Struct <code>hex_t</code> dismantled into parallel arrays.</strong> Weight, distance, heap index, air routes, and counters became separate flat arrays. This let the hot loop in Dijkstra touch only the data it actually needed per iteration instead of pulling an entire multi-field struct into cache.</li>\n<li><strong>Separation of permanent and working state</strong>. Distance and heap arrays were moved out of the per-node struct entirely, allocated once in <code>main()</code> and reused across queries. The permanent map shrank to just <code>weight[]</code>, <code>counter_air_route[]</code>, and <code>air_route[]</code>.</li>\n<li><strong>Bucket-based Dijkstra</strong> (post-submission refinement). The bounded-weight property <code class=\"formula-inline\">[0, 100]</code> made the binary heap overqualified. The 101-bucket Dial's variant eliminated the <code class=\"formula-inline\">\\log V</code> factor from priority operations, nearly halving execution time.</li>\n</ol>\n<p>Every optimization that worked was a removal of unnecessary state, not an addition of clever machinery.</p>\n<hr />\n<h2><strong>10. Why the Final Design Fits the Problem</strong></h2>\n<p>The final implementation works because the representation, the invariants, and the hot-path optimizations are aligned with the structure of the assignment:</p>\n<ul>\n<li>geometry is handled through an explicit offset/cube model, not square-grid intuition</li>\n<li>ground adjacency is derived on the fly because it never mutates</li>\n<li>air routes are stored explicitly because they are the only mutable adjacency</li>\n<li>air-route costs are implicit in <code>weight[]</code> because the specification guarantees a stable invariant</li>\n<li><code>change_cost</code> operates by geometric enumeration in cube space because the command is defined by hex distance</li>\n<li><code>travel_cost</code> is backed by Dijkstra with a three-state index tracker (heap variant) or stale-entry tolerance (bucket variant), with early exit on the destination</li>\n<li>repeated queries are cached with exact <code>(src, dst)</code> memoization and full invalidation on mutation</li>\n<li>bounded weights in <code class=\"formula-inline\">[0, 100]</code> justify replacing a generic <code class=\"formula-inline\">O(\\log V)</code> heap with a constant-time bucket structure</li>\n</ul>\n<p>The performance gain did not come from switching to a fancier shortest-path algorithm alone. It came mostly from making the data layout match the actual geometry, mutability, and bounded-weight structure of the problem: smaller permanent state, fewer cache misses, less pointer chasing, and less unnecessary memory traffic in the hot path.</p>","date_published":"2026-03-29T00:00:00.000Z","tags":["C","Graph Algorithms","Data Structures","Performance"]},{"id":"https://www.tommasovaccari.com/blog/from-bigrams-to-neural-networks","url":"https://www.tommasovaccari.com/blog/from-bigrams-to-neural-networks","title":"From Bigrams to Neural Networks: The First Step in Language Modeling","summary":"A hands-on walkthrough from count-based bigrams to a simple neural model.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2><strong>1. Motivation &amp; Goals</strong></h2>\n<p>In this post, we tackle one of the most fundamental problems in <a href=\"https://en.wikipedia.org/wiki/Natural_language_processing\" rel=\"noopener noreferrer\">natural language processing</a>: <a href=\"https://en.wikipedia.org/wiki/Language_model\" rel=\"noopener noreferrer\">language modeling</a>. Our concrete objective is to build a system that generates plausible Italian names by predicting the next character given the previous one.</p>\n<p>Language modeling assigns probabilities to token sequences by predicting the next token given its context. This simple idea underpins tools as familiar as autocomplete and as powerful as large-scale generative models.</p>\n<p>We begin with the <a href=\"https://en.wikipedia.org/wiki/N-gram\" rel=\"noopener noreferrer\">bigram</a> model: <code class=\"formula-inline\">P(x_t \\mid x_{t-1}).</code>\nThis model looks back exactly one step, capturing only immediate dependencies. Despite its simplicity, bigrams marked an early step beyond rule-based <a href=\"https://en.wikipedia.org/wiki/Formal_grammar\" rel=\"noopener noreferrer\">formal grammars</a>.</p>\n<p>From there, we reformulate the bigram as a neural network. This isn’t just reimplementation for its own sake: it shows how fixed statistical tables can be generalized into trainable systems, the very principle that scales into today’s large language models.</p>\n<p>With the goal defined, we’re ready to start from the basics.</p>\n<hr />\n<h2><strong>2. Dataset &amp; Preprocessing</strong></h2>\n<p>We start from data: <a href=\"https://figshare.com/articles/dataset/italian_names_first_5000_xlsx/3839580?utm_source=chatgpt.com&amp;file=5999643\" rel=\"noopener noreferrer\">the ISTAT list of Italian given names</a>. The raw text contains diacritics, apostrophes, hyphens, spaces, and mixed case. To make the first model tractable, we project the text to a reduced alphabet and remove extremely short items. Formally, we apply a mapping</p>\n<code class=\"formula-inline\">\\phi : \\Sigma_{\\text{raw}} \\;\\to\\; \\Sigma_{\\text{ascii}} = \\{a, \\ldots, z\\}</code>\n<p>by lowercasing, <a href=\"https://en.wikipedia.org/wiki/Unicode_normalization\" rel=\"noopener noreferrer\">Unicode-normalizing</a>, and stripping non-letters; the model is trained on <code class=\"formula-inline\">\\phi(\\text{names})</code>.</p>\n<p>Normalization choices (trade-offs):</p>\n<ol>\n<li><strong>Lowercase</strong> to collapse case variants.</li>\n<li><strong><a href=\"https://en.wikipedia.org/wiki/Diacritic\" rel=\"noopener noreferrer\">Diacritics</a> → base letters</strong> via Unicode decomposition (e.g., Niccolò → niccolo). This shrinks the vocabulary and speeds up training, at the cost of losing some orthographic distinctions.</li>\n<li><strong>Drop very short names</strong> (<code>len &lt; 3</code>) to reduce degenerate contexts and stabilize bigram counts.</li>\n</ol>\n<p>Minimal, deterministic preprocessing:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>import</span><span> re</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>with</span><span> open</span><span>(</span><span>\"italian_names.txt\"</span><span>, </span><span>\"r\"</span><span>, </span><span>encoding</span><span>=</span><span>\"utf-8\"</span><span>) </span><span>as</span><span> f:</span></span>\n<span class=\"line\"><span>    names </span><span>=</span><span> [line.strip().lower() </span><span>for</span><span> line </span><span>in</span><span> f </span><span>if</span><span> line.strip()]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>def</span><span> normalize</span><span>(name):</span></span>\n<span class=\"line\"><span>    # keep only letters a–z</span></span>\n<span class=\"line\"><span>    return</span><span> re.sub(</span><span>r</span><span>'</span><span>[</span><span>^</span><span>a-z]</span><span>'</span><span>, </span><span>''</span><span>, name)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Clean and filter</span></span>\n<span class=\"line\"><span>names </span><span>=</span><span> [n </span><span>for</span><span> n </span><span>in</span><span> (normalize(name) </span><span>for</span><span> name </span><span>in</span><span> names) </span><span>if</span><span> len</span><span>(n) </span><span>&gt;=</span><span> 3</span><span>]</span></span>\n<span class=\"line\"><span>names </span><span>=</span><span> list</span><span>(</span><span>set</span><span>(names)) </span><span>#Now we have a set of uniques names</span></span></code></pre>\n<p>After preprocessing, we are left with around 15,000 names, which is plenty of data to estimate bigram statistics and to train our first simple models.</p>\n<hr />\n<h2><strong>3. Vocabulary &amp; Tokenization</strong></h2>\n<p>Tokens are the atomic units of a language model. In this project, each character is a token, so the vocabulary is simply the set of unique letters in the preprocessed dataset. To handle sequences cleanly, we extend this set with two boundary markers: ! for start-of-sequence (SOS) and ? for end-of-sequence (EOS).\nFormally, we construct a bijection</p>\n<div class=\"formula\"><code>\\text{stoi} : \\Sigma \\cup \\{\\text{SOS}, \\text{EOS}\\} \\;\\to\\; \\{0, \\ldots, V-1\\}</code></div>\n<p>and its inverse itos. Encoding applies stoi elementwise to a string; decoding applies itos to a list of indices.\nHere’s the code:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Build character set from dataset</span></span>\n<span class=\"line\"><span>characters </span><span>=</span><span> sorted</span><span>(</span><span>set</span><span>(</span><span>\"\"</span><span>.join(names)))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Define special tokens first </span></span>\n<span class=\"line\"><span>stoi </span><span>=</span><span> {</span><span>\"!\"</span><span>: </span><span>0</span><span>}  </span><span># SOS</span></span>\n<span class=\"line\"><span>for</span><span> i, ch </span><span>in</span><span> enumerate</span><span>(characters, </span><span>start</span><span>=</span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    stoi[ch] </span><span>=</span><span> i</span></span>\n<span class=\"line\"><span>stoi[</span><span>\"?\"</span><span>] </span><span>=</span><span> len</span><span>(stoi)  </span><span># EOS</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>itos </span><span>=</span><span> {i: ch </span><span>for</span><span> ch, i </span><span>in</span><span> stoi.items()}</span></span>\n<span class=\"line\"><span>vocab_size </span><span>=</span><span> len</span><span>(stoi)</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>f</span><span>\"Vocabulary size: </span><span>{</span><span>vocab_size</span><span>}</span><span>\"</span><span>)</span></span></code></pre>\n<p>To illustrate how encoding and decoding work with these mappings, consider the example of the name \"tommaso\":</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>name </span><span>=</span><span> \"!tommaso?\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Encode: convert each character to its index</span></span>\n<span class=\"line\"><span>encoded </span><span>=</span><span> [stoi[ch] </span><span>for</span><span> ch </span><span>in</span><span> name]</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"Encoded:\"</span><span>, encoded)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Decode: convert indices back to characters</span></span>\n<span class=\"line\"><span>decoded </span><span>=</span><span> ''</span><span>.join([itos[ix] </span><span>for</span><span> ix </span><span>in</span><span> encoded])</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>\"Decoded:\"</span><span>, decoded)</span></span></code></pre>\n<p>The output will be:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>Encoded: [</span><span>0</span><span>, </span><span>20</span><span>, </span><span>15</span><span>, </span><span>13</span><span>, </span><span>13</span><span>, </span><span>1</span><span>, </span><span>19</span><span>, </span><span>15</span><span>, </span><span>27</span><span>]</span></span>\n<span class=\"line\"><span>Decoded: </span><span>!</span><span>tommaso</span><span>?</span></span></code></pre>\n<p>This mapping turns text into a numerical sequence and back again. By inserting SOS and EOS during training, the model learns both when names start and when they should stop — essential for generating coherent results.</p>\n<hr />\n<h2><strong>4. Creation of the Training and Test Sets</strong></h2>\n<p>To evaluate a model fairly, we split the dataset into disjoint parts: a training set to fit parameters and a test set to measure generalization. For this experiment, we use an 80/20 split. Larger projects often add a separate validation set for hyperparameter tuning, but two splits suffice here.\nFormally, each name is converted into a sequence of pairs <code class=\"formula-inline\">(X_t, Y_t)</code>, where</p>\n<div class=\"formula\"><code>X_t = x_{t-1}, \\quad Y_t = x_t, \\quad \\text{with } x_0 = \\text{SOS}, \\; x_{n+1} = \\text{EOS}.</code></div>\n<p>Each pair encodes the prediction task: “given the current character, predict the next one.”\nImplementation in PyTorch:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>import</span><span> torch, random</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>def</span><span> create_dataset</span><span>(names, block_size</span><span>=</span><span>1</span><span>):</span></span>\n<span class=\"line\"><span>    X, Y </span><span>=</span><span> [], []</span></span>\n<span class=\"line\"><span>    for</span><span> name </span><span>in</span><span> names:</span></span>\n<span class=\"line\"><span>        prev_ix </span><span>=</span><span> stoi[</span><span>\"!\"</span><span>]  </span><span># SOS</span></span>\n<span class=\"line\"><span>        for</span><span> ch </span><span>in</span><span> name </span><span>+</span><span> \"?\"</span><span>:  </span><span># append EOS</span></span>\n<span class=\"line\"><span>            ix </span><span>=</span><span> stoi[ch]</span></span>\n<span class=\"line\"><span>            X.append(prev_ix)</span></span>\n<span class=\"line\"><span>            Y.append(ix)</span></span>\n<span class=\"line\"><span>            prev_ix </span><span>=</span><span> ix</span></span>\n<span class=\"line\"><span>    return</span><span> torch.tensor(X, </span><span>dtype</span><span>=</span><span>torch.long), torch.tensor(Y, </span><span>dtype</span><span>=</span><span>torch.long)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Train/test split </span></span>\n<span class=\"line\"><span>names_shuffled </span><span>=</span><span> names[:]</span></span>\n<span class=\"line\"><span>random.shuffle(names_shuffled)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>split_idx </span><span>=</span><span> int</span><span>(</span><span>0.8</span><span> *</span><span> len</span><span>(names_shuffled))</span></span>\n<span class=\"line\"><span>Xtr, Ytr </span><span>=</span><span> create_dataset(names_shuffled[:split_idx])</span></span>\n<span class=\"line\"><span>Xtst, Ytst </span><span>=</span><span> create_dataset(names_shuffled[split_idx:])</span></span></code></pre>\n<p>At this point, we have compact tensor representations of the training and test data, ready to be fed into the bigram model.\nTo make this concrete, let’s look at how the name \"tommaso\" is represented:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>X, Y </span><span>=</span><span> create_dataset([</span><span>\"tommaso\"</span><span>])</span></span>\n<span class=\"line\"><span>for</span><span> i </span><span>in</span><span> range</span><span>(</span><span>len</span><span>(X)):</span></span>\n<span class=\"line\"><span>    print</span><span>(itos[X[i].item()], </span><span>\"--&gt;\"</span><span>, itos[Y[i].item()])</span></span></code></pre>\n<p>Output:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>! </span><span>--</span><span>&gt;</span><span> t</span></span>\n<span class=\"line\"><span>t </span><span>--</span><span>&gt;</span><span> o</span></span>\n<span class=\"line\"><span>o </span><span>--</span><span>&gt;</span><span> m</span></span>\n<span class=\"line\"><span>m </span><span>--</span><span>&gt;</span><span> m</span></span>\n<span class=\"line\"><span>m </span><span>--</span><span>&gt;</span><span> a</span></span>\n<span class=\"line\"><span>a </span><span>--</span><span>&gt;</span><span> s</span></span>\n<span class=\"line\"><span>s </span><span>--</span><span>&gt;</span><span> o</span></span>\n<span class=\"line\"><span>o </span><span>--</span><span>&gt;</span><span> ?</span></span></code></pre>\n<p>This example illustrates the idea: each pair <code class=\"formula-inline\">(X, Y)</code> captures a step in the sequence, mapping the current character (or <code class=\"formula-inline\">\\text{SOS}</code> at the start) to the next one. The final transition predicts <code class=\"formula-inline\">\\text{EOS}</code>, teaching the model when to stop generating.</p>\n<hr />\n<h2><strong>5. From Counts to Probabilities</strong></h2>\n<p>With the training pairs in hand, we can now estimate conditional probabilities.\nThe first step is to build a <code class=\"formula-inline\">V \\times V</code> count matrix, where <code class=\"formula-inline\">V</code> is the vocabulary size and</p>\n<div class=\"formula\"><code>C[i,j] = \\#\\{\\text{times token }j\\text{ follows token }i\\}.</code></div>\n<p>This matrix encodes the immediate statistical structure of the dataset. By <a href=\"https://en.wikipedia.org/wiki/Maximum_likelihood_estimation\" rel=\"noopener noreferrer\">maximum likelihood estimation (MLE)</a> for a <a href=\"https://en.wikipedia.org/wiki/Categorical_distribution\" rel=\"noopener noreferrer\">categorical distribution</a>:</p>\n<div class=\"formula\"><code>\\hat{P}(j \\mid i) = \\frac{C[i,j]}{\\sum_k C[i,k]}.</code></div>\n<p>Intuitively, the chance of seeing <code class=\"formula-inline\">j</code> after <code class=\"formula-inline\">i</code> is just its observed frequency.</p>\n<h3>Counting Character Transitions</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Bigram counts matrix: V x V where C[i, j] = times char j follows char i</span></span>\n<span class=\"line\"><span>counts </span><span>=</span><span> torch.zeros(</span><span>len</span><span>(itos), </span><span>len</span><span>(itos))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> i, j </span><span>in</span><span> zip</span><span>(Xtr, Ytr):</span></span>\n<span class=\"line\"><span>    counts[i][j] </span><span>+=</span><span> 1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Add-one smoothing to avoid zero probabilities ([additive smoothing](https://en.wikipedia.org/wiki/Additive_smoothing))</span></span>\n<span class=\"line\"><span>counts </span><span>+=</span><span> 1</span></span></code></pre>\n<p>Resulting counts (heatmap visualization):</p>\n<p><img src=\"https://www.tommasovaccari.com/static/counts-7504e101.webp\" alt=\"Counts\" /></p>\n<p>The counts heatmap shows raw transition frequencies: brighter cells indicate more common bigrams\n(row = current character, column = next character, i.e. row <code class=\"formula-inline\">i</code>, column <code class=\"formula-inline\">j</code> corresponds to <code class=\"formula-inline\">C[i,j]</code>).</p>\n<h3>Converting Counts to Probabilities</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Normalize each row to sum to 1</span></span>\n<span class=\"line\"><span>probs </span><span>=</span><span> counts </span><span>/</span><span> counts.sum(</span><span>1</span><span>, </span><span>keepdim</span><span>=</span><span>True</span><span>)</span></span></code></pre>\n<p>Each row of probs is a <a href=\"https://en.wikipedia.org/wiki/Categorical_distribution\" rel=\"noopener noreferrer\">categorical distribution</a> over the next token, and <a href=\"https://en.wikipedia.org/wiki/Additive_smoothing\" rel=\"noopener noreferrer\">Laplace smoothing</a> ensures even rare contexts yield valid probabilities.\nFinal probability distribution(heatmap visualization):</p>\n<p><img src=\"https://www.tommasovaccari.com/static/probs-6087aac8.webp\" alt=\"Probs\" /></p>\n<p>The probability heatmap shows per-row normalized transitions: each row sums to 1, highlighting the most likely successors for every character.</p>\n<h3>Sampling from the Bigram Model</h3>\n<p>Now that we have probabilities, we can sample new names. The procedure is:</p>\n<ol>\n<li>Start with the SOS token (!).</li>\n<li>Retrieve the probability distribution for the next character.</li>\n<li>Sample from it using <a href=\"https://pytorch.org/docs/stable/generated/torch.multinomial.html\" rel=\"noopener noreferrer\">torch.multinomial</a>.</li>\n<li>Append the sampled character and update the context.</li>\n<li>Stop when EOS (?) is reached or a maximum length is exceeded.</li>\n</ol>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>def</span><span> generate</span><span>(n_samples, maxlen</span><span>=</span><span>15</span><span>):</span></span>\n<span class=\"line\"><span>    for</span><span> _ </span><span>in</span><span> range</span><span>(n_samples):</span></span>\n<span class=\"line\"><span>        context </span><span>=</span><span> 0</span><span>  # SOS</span></span>\n<span class=\"line\"><span>        s </span><span>=</span><span> \"\"</span></span>\n<span class=\"line\"><span>        for</span><span> _ </span><span>in</span><span> range</span><span>(maxlen):</span></span>\n<span class=\"line\"><span>            prob </span><span>=</span><span> probs[context]         </span><span># distribution over next char</span></span>\n<span class=\"line\"><span>            idx </span><span>=</span><span> torch.multinomial(prob, </span><span>1</span><span>).item()</span></span>\n<span class=\"line\"><span>            context </span><span>=</span><span> idx</span></span>\n<span class=\"line\"><span>            ch </span><span>=</span><span> itos[idx]</span></span>\n<span class=\"line\"><span>            if</span><span> ch </span><span>==</span><span> \"?\"</span><span>:</span></span>\n<span class=\"line\"><span>                break</span></span>\n<span class=\"line\"><span>            s </span><span>+=</span><span> ch</span></span>\n<span class=\"line\"><span>        print</span><span>(s)</span></span></code></pre>\n<p>Example Output:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>eli</span></span>\n<span class=\"line\"><span>dafo</span></span>\n<span class=\"line\"><span>ada</span></span>\n<span class=\"line\"><span>haariva</span></span>\n<span class=\"line\"><span>anna</span></span>\n<span class=\"line\"><span>minla</span></span>\n<span class=\"line\"><span>gila</span></span>\n<span class=\"line\"><span>coud</span></span></code></pre>\n<p>Most outputs are noisy or unrealistic, which is expected: the bigram model can only capture relationships between adjacent characters. Still, some generated names look plausible, such as:</p>\n<ul>\n<li>eli</li>\n<li>anna</li>\n</ul>\n<p>This shows the model has picked up some local character patterns, but it fails to capture longer-range dependencies needed for realistic names.</p>\n<p>A natural idea is to extend the context, e.g., moving from bigrams to trigrams. But this quickly becomes impractical: the size of the count matrix scales as <code class=\"formula-inline\">O(V^n)</code>, growing polynomially with <code class=\"formula-inline\">V</code> and exponentially with <code class=\"formula-inline\">n</code>, and probabilities become sparse as context length increases. The true solution lies in a different direction — neural networks, which we will explore in the next section.</p>\n<hr />\n<h2><strong>6. Neural View of the Model</strong></h2>\n<p>The bigram model was just a lookup table of observed transitions. A neural model reframes this idea as a trainable function.</p>\n<p>The setup is unchanged: given a context (initially one previous character), the model predicts the next one.\nThe difference is representation. Instead of fixed rows in a matrix, we introduce <strong><a href=\"https://en.wikipedia.org/wiki/Word_embedding\" rel=\"noopener noreferrer\">embeddings</a></strong>: a learnable map</p>\n<div class=\"formula\"><code>\\text{Emb} : \\{0, \\ldots, V-1\\} \\;\\to\\; \\mathbb{R}^d,</code></div>\n<p>which assigns each token index a vector in a <code class=\"formula-inline\">d</code>-dimensional continuous space.</p>\n<p>Architecture sketch:</p>\n<ol>\n<li>Input: previous character index.</li>\n<li>Embedding layer: lookup dense vector.</li>\n<li>Linear layer: transform vector to <a href=\"https://en.wikipedia.org/wiki/Logit\" rel=\"noopener noreferrer\">logits</a></li>\n<li><a href=\"https://en.wikipedia.org/wiki/Rectifier_(neural_networks)\" rel=\"noopener noreferrer\">ReLU</a> activation: adds nonlinearity, breaking equivalence with a pure lookup table.</li>\n<li>Softmax: converts logits into probabilities.</li>\n</ol>\n<h3>Linear Transformation and Nonlinearity</h3>\n<p>After retrieving an embedding vector for the input token, the next step is to transform it into a representation that can be compared against every possible output class. This is done through a <strong>linear transformation</strong>:</p>\n<code class=\"formula-inline\">z = W x + b</code>\n<p>where</p>\n<ul>\n<li><code class=\"formula-inline\">x \\in \\mathbb{R}^d</code> is the embedding of the current token,</li>\n<li><code class=\"formula-inline\">W \\in \\mathbb{R}^{V \\times d}</code> is a weight matrix mapping the <code class=\"formula-inline\">d</code>-dimensional embedding space to the <code class=\"formula-inline\">V</code> output classes,</li>\n<li><code class=\"formula-inline\">b \\in \\mathbb{R}^V</code> is a bias vector, and</li>\n<li><code class=\"formula-inline\">z \\in \\mathbb{R}^V</code> are the resulting <strong>logits</strong>, one score for each vocabulary item.</li>\n</ul>\n<p>This step assigns a learnable score to each possible next token.</p>\n<h4>Why Nonlinearity?</h4>\n<p>If the model consisted only of embeddings and a single linear transformation, it would still be a linear function of the input indices. In fact, a single linear layer is mathematically equivalent to a lookup table — expressive enough for bigrams, but fundamentally limited. With <a href=\"https://en.wikipedia.org/wiki/One-hot\" rel=\"noopener noreferrer\">one-hot</a> input vectors, a linear transformation <code class=\"formula-inline\">W x</code> just selects a row of <code class=\"formula-inline\">W</code>. This is functionally identical to table lookup. Only nonlinearities break this equivalence.</p>\n<p>To break this limitation, we introduce a <strong>nonlinear activation function</strong> between layers. In practice, we use the <strong>ReLU</strong> (Rectified Linear Unit):</p>\n<code class=\"formula-inline\">\\text{ReLU}(z) = \\max(0, z)</code>\n<p>ReLU has two critical effects:</p>\n<ol>\n<li>It allows the model to compose multiple linear layers into a genuinely nonlinear function, greatly expanding its representational capacity.</li>\n<li>It zeroes negative activations, which can induce sparse intermediate representations and often improves optimization stability.</li>\n</ol>\n<p>Together, the <strong>linear layer</strong> provides a weighted combination of input features, while the <strong>nonlinearity</strong> ensures the model is not just a rescaled lookup table. This combination forms the backbone of neural networks and prepares the logits for conversion into probabilities via the softmax function.</p>\n<h3>The Role of Softmax</h3>\n<p>The linear layer produces a vector of real-valued scores, or <strong>logits</strong>, one per token in the vocabulary.\nThese scores are not probabilities. To turn them into a distribution, we apply the <strong><a href=\"https://en.wikipedia.org/wiki/Softmax_function\" rel=\"noopener noreferrer\">softmax function</a></strong>:</p>\n<div class=\"formula\"><code>\\text{softmax}(z)_j \\;=\\; \\frac{e^{z_j}}{\\sum_{k=1}^V e^{z_k}}, \\quad j = 1, \\ldots, V</code></div>\n<p>where <code class=\"formula-inline\">z \\in \\mathbb{R}^V</code> is the logits vector and <code class=\"formula-inline\">V</code> is the vocabulary size.</p>\n<p>Softmax has three key properties:</p>\n<ul>\n<li>All outputs are <strong>non-negative</strong>.</li>\n<li>The outputs sum to <strong>1</strong>, yielding a valid probability distribution.</li>\n<li>The exponential transformation sharpens differences, amplifying the highest scores.</li>\n</ul>\n<h3>Cross-Entropy Loss and Maximum Likelihood</h3>\n<p>At a statistical level, we assume the data is generated by an unknown distribution <code class=\"formula-inline\">P_{\\text{data}}</code> over classes <code class=\"formula-inline\">\\{1, \\dots, V\\}</code>.\nWe define a parametric model <code class=\"formula-inline\">P_\\theta</code>, with parameters <code class=\"formula-inline\">\\theta</code>, that assigns probabilities to these classes.\nTraining aims to choose <code class=\"formula-inline\">\\theta</code> so that <code class=\"formula-inline\">P_\\theta</code> approximates <code class=\"formula-inline\">P_{\\text{data}}</code> as closely as possible.</p>\n<h4>Maximum Likelihood Estimation</h4>\n<p>The principle of <strong><a href=\"https://en.wikipedia.org/wiki/Maximum_likelihood_estimation\" rel=\"noopener noreferrer\">maximum likelihood estimation (MLE)</a></strong> chooses <code class=\"formula-inline\">\\theta</code> to maximize the probability of the observed data.\nGiven observations <code class=\"formula-inline\">\\{y^{(1)}, \\dots, y^{(N)}\\}</code>:</p>\n<div class=\"formula\"><code>L(\\theta) = \\prod_{n=1}^N P_\\theta\\big(y^{(n)}\\big)</code></div>\n<div class=\"formula\"><code>\\ell(\\theta) = \\sum_{n=1}^N \\log P_\\theta\\big(y^{(n)}\\big)</code></div>\n<p>The MLE estimator is:</p>\n<div class=\"formula\"><code>\\hat{\\theta} = \\arg \\max_\\theta \\, \\ell(\\theta).</code></div>\n<h4>Negative Log-Likelihood and Cross-Entropy</h4>\n<p>Maximizing log-likelihood is equivalent to minimizing the <strong>negative log-likelihood (NLL)</strong>:</p>\n<div class=\"formula\"><code>\\mathcal{L}_{\\text{NLL}}(\\theta) = - \\sum_{n=1}^N \\log P_\\theta\\big(y^{(n)}\\big)</code></div>\n<p>For classification with one-hot targets <code class=\"formula-inline\">y^{(n)}</code> and model predictions <code class=\"formula-inline\">p_\\theta^{(n)}</code>, this becomes:</p>\n<div class=\"formula\"><code>\\mathcal{L}(y, p_\\theta) = - \\sum_{j=1}^V y_j \\, \\log p_{\\theta, j}</code></div>\n<p>This is exactly the <strong><a href=\"https://en.wikipedia.org/wiki/Cross-entropy\" rel=\"noopener noreferrer\">cross-entropy</a></strong> between the empirical distribution <code class=\"formula-inline\">y</code> and the model distribution <code class=\"formula-inline\">p_\\theta</code>.\nMinimizing cross-entropy is equivalent to minimizing the <strong><a href=\"https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence\" rel=\"noopener noreferrer\">KL divergence</a></strong>:</p>\n<div class=\"formula\"><code>D_{\\text{KL}}(P_{\\text{data}} \\,||\\, P_\\theta),</code></div>\n<p>the gold standard of statistical approximation.</p>\n<h4>Optimization via Gradient Descent</h4>\n<p>In the count-based model, MLE had a closed form: normalize counts.\nIn a neural model, the mapping <code class=\"formula-inline\">x \\mapsto p_\\theta</code> is nonlinear, so no closed form exists.\nInstead, we compute gradients:</p>\n<div class=\"formula\"><code>\\nabla_\\theta \\, \\mathcal{L}_{\\text{NLL}}(\\theta)</code></div>\n<p>and update parameters iteratively using optimization algorithms such as <a href=\"https://en.wikipedia.org/wiki/Gradient_descent\" rel=\"noopener noreferrer\">gradient descent</a> and <a href=\"https://en.wikipedia.org/wiki/Stochastic_gradient_descent\" rel=\"noopener noreferrer\">stochastic gradient descent (SGD)</a>, or Adam.\nThis drives <code class=\"formula-inline\">P_\\theta</code> toward the distribution that maximizes the likelihood of the training data.</p>\n<h3>Worked Example: From Logits to Loss</h3>\n<p>Suppose our model outputs logits for <code class=\"formula-inline\">V = 3</code> classes:</p>\n<code class=\"formula-inline\">z = [2, 1, 0]</code>\n<p><strong>Step 1: Apply Softmax</strong></p>\n<code class=\"formula-inline\">\\text{softmax}(z) = [0.67, 0.24, 0.09]</code>\n<p><strong>Step 2: Define the True Label</strong></p>\n<p>Let the correct class be <code class=\"formula-inline\">y^* = 2</code>, i.e. <code class=\"formula-inline\">y = [0, 1, 0]</code>.</p>\n<p><strong>Step 3: Compute Cross-Entropy Loss</strong></p>\n<code class=\"formula-inline\">\\mathcal{L}(y, p) = - \\log p_{y^*} = - \\log 0.24 \\approx 1.43</code>\n<p>Interpretation:</p>\n<ul>\n<li>The correct class had probability only <code class=\"formula-inline\">0.24</code>, yielding a relatively high loss.</li>\n<li>If the probability were closer to <code class=\"formula-inline\">1</code>, the loss would approach <code class=\"formula-inline\">0</code>.</li>\n<li>Gradient descent will adjust parameters to increase <code class=\"formula-inline\">p_{y^*}</code>, reducing loss over time.</li>\n</ul>\n<h3>Key Insight</h3>\n<p>The count matrix was a fixed table; the neural model is a learnable table. With embeddings, nonlinear layers, and cross-entropy optimization, we generalize the same principle — conditional probabilities — into a form that scales.</p>\n<hr />\n<h2><strong>7. Dataset Creation, Model Instantiation &amp; Training</strong></h2>\n<p>We now move from bigrams (context length 1) to a fixed-width context window of length 3. This allows the model to consider not just the immediately preceding character but a short history. The workflow has three parts:</p>\n<ol>\n<li>Dataset creation with a sliding window,</li>\n<li>Model instantiation (Embedding → <a href=\"https://en.wikipedia.org/wiki/Multilayer_perceptron\" rel=\"noopener noreferrer\">MLP</a> → logits),</li>\n<li>Training &amp; evaluation (cross-entropy + <a href=\"https://pytorch.org/docs/stable/generated/torch.optim.Adam.html\" rel=\"noopener noreferrer\">Adam</a>).</li>\n</ol>\n<p>Conventions.\n! = start-of-sequence (SOS, index 0),\n? = end-of-sequence (EOS),\nblock_size = 3 = context length.\nAll tensors live on device.</p>\n<h3>Dataset (Sliding Context Window)</h3>\n<p>The dataset construction mirrors the bigram setup but with a sliding window of length 3:</p>\n<ul>\n<li>Left-pad each name with three SOS tokens and append EOS.</li>\n<li>Slide a 3-character window across the sequence to form (context, target) pairs.</li>\n<li>Stack all pairs into tensors X (shape (N, 3)) and Y (shape (N,)).</li>\n</ul>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>block_size </span><span>=</span><span> 3</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>def</span><span> create_dataset</span><span>(names):</span></span>\n<span class=\"line\"><span>    X, Y </span><span>=</span><span> [], []</span></span>\n<span class=\"line\"><span>    for</span><span> name </span><span>in</span><span> names:</span></span>\n<span class=\"line\"><span>        context </span><span>=</span><span> [</span><span>0</span><span>] </span><span>*</span><span> block_size  </span><span># SOS padding</span></span>\n<span class=\"line\"><span>        for</span><span> ch </span><span>in</span><span> name </span><span>+</span><span> \"?\"</span><span>:</span></span>\n<span class=\"line\"><span>            ix </span><span>=</span><span> stoi[ch]</span></span>\n<span class=\"line\"><span>            X.append(context[:])     </span><span># copy current context</span></span>\n<span class=\"line\"><span>            Y.append(ix)</span></span>\n<span class=\"line\"><span>            context </span><span>=</span><span> context[</span><span>1</span><span>:] </span><span>+</span><span> [ix]</span></span>\n<span class=\"line\"><span>    return</span><span> torch.tensor(X, </span><span>dtype</span><span>=</span><span>torch.long), torch.tensor(Y, </span><span>dtype</span><span>=</span><span>torch.long)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>Xtr, Ytr </span><span>=</span><span> create_dataset(names[:split_idx])</span></span>\n<span class=\"line\"><span>Xtst, Ytst </span><span>=</span><span> create_dataset(names[split_idx:])</span></span></code></pre>\n<h3>Model: Embedding → MLP → Logits</h3>\n<p>The neural network extends the count-based model into a parameterized function. Its architecture:</p>\n<ol>\n<li>Embedding layer: converts discrete indices into dense vectors. (<a href=\"https://pytorch.org/docs/stable/generated/torch.nn.Embedding.html\" rel=\"noopener noreferrer\">nn.Embedding</a>)</li>\n<li>Flatten + Linear layer: projects concatenated embeddings into a hidden space. (<a href=\"https://pytorch.org/docs/stable/generated/torch.nn.Linear.html\" rel=\"noopener noreferrer\">nn.Linear</a>)</li>\n<li>ReLU activation: adds nonlinearity, letting the model learn beyond a lookup table. (<a href=\"https://pytorch.org/docs/stable/generated/torch.nn.functional.relu.html\" rel=\"noopener noreferrer\">torch.nn.functional.relu</a>)</li>\n<li>Output layer: maps hidden features to logits for each vocabulary token.</li>\n<li>Softmax (inside the loss): converts logits into probabilities.</li>\n</ol>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>vocab_size    </span><span>=</span><span> len</span><span>(itos)</span></span>\n<span class=\"line\"><span>embedding_dim </span><span>=</span><span> 32</span></span>\n<span class=\"line\"><span>hidden_dim    </span><span>=</span><span> 128</span></span>\n<span class=\"line\"><span>block_size    </span><span>=</span><span> 3</span><span>  # context length</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>class</span><span> Net</span><span>(</span><span>nn</span><span>.</span><span>Module</span><span>):</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, vocab_size, embedding_dim, block_size</span><span>=</span><span>3</span><span>, hidden_dim</span><span>=</span><span>128</span><span>):</span></span>\n<span class=\"line\"><span>        super</span><span>().</span><span>__init__</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.emb </span><span>=</span><span> nn.Embedding(vocab_size, embedding_dim)              </span><span># (V, d)</span></span>\n<span class=\"line\"><span>        self</span><span>.fc1 </span><span>=</span><span> nn.Linear(block_size </span><span>*</span><span> embedding_dim, hidden_dim)    </span><span># (3d → H)</span></span>\n<span class=\"line\"><span>        self</span><span>.fc2 </span><span>=</span><span> nn.Linear(hidden_dim, vocab_size)                    </span><span># (H → V)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        x: (B, block_size) int64</span></span>\n<span class=\"line\"><span>        returns: logits (B, V)</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        e </span><span>=</span><span> self</span><span>.emb(x)                </span><span># (B, block_size, d)</span></span>\n<span class=\"line\"><span>        e </span><span>=</span><span> e.view(e.size(</span><span>0</span><span>), </span><span>-</span><span>1</span><span>)      </span><span># flatten to (B, block_size*d) [docs](https://pytorch.org/docs/stable/generated/torch.Tensor.view.html)</span></span>\n<span class=\"line\"><span>        h </span><span>=</span><span> F.relu(</span><span>self</span><span>.fc1(e))        </span><span># (B, H)</span></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> self</span><span>.fc2(h)           </span><span># (B, V)</span></span>\n<span class=\"line\"><span>        return</span><span> logits</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>model </span><span>=</span><span> Net(vocab_size, embedding_dim, block_size, hidden_dim).to(device)</span></span></code></pre>\n<h3>Loss, Optimizer, and Mini-batching</h3>\n<ul>\n<li>Loss function:<a href=\"https://pytorch.org/docs/stable/generated/torch.nn.functional.cross_entropy.html\" rel=\"noopener noreferrer\">F.cross_entropy</a> combines softmax + negative log-likelihood. This corresponds to maximum likelihood estimation (MLE): maximizing the probability of the training data.</li>\n<li>Optimizer: <a href=\"https://pytorch.org/docs/stable/generated/torch.optim.Adam.html\" rel=\"noopener noreferrer\">torch.optim.Adam</a> with mild L2 regularization via weight_decay. <a href=\"https://pytorch.org/docs/stable/generated/torch.optim.Adam.html\" rel=\"noopener noreferrer\">Adam</a> adaptively scales learning rates per parameter using running averages of gradients (first moment) and squared gradients (second moment).</li>\n<li>Mini-batching: we sample random training examples with <a href=\"https://pytorch.org/docs/stable/generated/torch.randint.html\" rel=\"noopener noreferrer\">torch.randint</a>.</li>\n</ul>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>batch_size </span><span>=</span><span> 256</span></span>\n<span class=\"line\"><span>optimizer  </span><span>=</span><span> torch.optim.Adam(model.parameters(), </span><span>lr</span><span>=</span><span>3e-3</span><span>, </span><span>weight_decay</span><span>=</span><span>1.5e-4</span><span>)</span></span></code></pre>\n<p>Optimization logic:\nFor each mini-batch, compute logits → cross-entropy → scalar loss. Backpropagation computes <code class=\"formula-inline\">\\nabla_\\theta \\mathcal{L}</code>. <a href=\"https://pytorch.org/docs/stable/generated/torch.optim.Adam.html\" rel=\"noopener noreferrer\">Adam</a> then updates parameters, inching the model closer to the distribution that maximizes likelihood of the observed data.</p>\n<h3>Training Loop (Mini-batch SGD)</h3>\n<p>This is where the model learns. We run stochastic gradient descent (SGD) in mini-batches, using random subsets of training data to approximate gradients efficiently while introducing noise that improves generalization.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>model.train()</span></span>\n<span class=\"line\"><span>epochs          </span><span>=</span><span> 3</span><span>         # Chosen to balance training stability with runtime; increase for stronger convergence.</span></span>\n<span class=\"line\"><span>iters_per_epoch </span><span>=</span><span> 10_000</span><span>    # Chosen to balance training stability with runtime; increase for stronger convergence.</span></span>\n<span class=\"line\"><span>loss_trace </span><span>=</span><span> []</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>N </span><span>=</span><span> Xtr.size(</span><span>0</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>for</span><span> _ </span><span>in</span><span> range</span><span>(epochs):</span></span>\n<span class=\"line\"><span>    for</span><span> _ </span><span>in</span><span> range</span><span>(iters_per_epoch):</span></span>\n<span class=\"line\"><span>        idx </span><span>=</span><span> torch.randint(</span><span>0</span><span>, N, (batch_size,), </span><span>device</span><span>=</span><span>device)  </span><span># (B,)</span></span>\n<span class=\"line\"><span>        Xb  </span><span>=</span><span> Xtr[idx]                                           </span><span># (B, 3)</span></span>\n<span class=\"line\"><span>        yb  </span><span>=</span><span> Ytr[idx]                                           </span><span># (B,)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        logits </span><span>=</span><span> model(Xb)                </span><span># (B, V)</span></span>\n<span class=\"line\"><span>        loss   </span><span>=</span><span> F.cross_entropy(logits, yb)</span></span>\n<span class=\"line\"><span>        loss_trace.append(loss.item())</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        optimizer.zero_grad(</span><span>set_to_none</span><span>=</span><span>True</span><span>)  </span><span># [docs](https://pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html)</span></span>\n<span class=\"line\"><span>        loss.backward()                         </span><span># backpropagation</span></span>\n<span class=\"line\"><span>        optimizer.step()                        </span><span># parameter update</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(</span><span>f</span><span>\"train loss (last batch): </span><span>{</span><span>loss.item()</span><span>:.4f</span><span>}</span><span>\"</span><span>)</span></span></code></pre>\n<p>Below is the training loss curve over iterations:\n<img src=\"https://www.tommasovaccari.com/static/lossi-25b58328.webp\" alt=\"png\" /></p>\n<p>The loss curve decreases overall but exhibits some variance, indicating that further hyperparameter tuning could improve performance. The initial ‘hockey-stick’ shape might also be mitigated with a better weight initialization strategy. However, for our purposes, we can proceed with the current setup and keep the approach simple.</p>\n<h3>Evaluation (Held-out Mini-batch)</h3>\n<p>Evaluation is done on unseen data. We disable gradient tracking with <a href=\"https://pytorch.org/docs/stable/generated/torch.no_grad.html\" rel=\"noopener noreferrer\">torch.no_grad</a>, switch to evaluation mode, and compute both loss and accuracy.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@torch.no_grad</span><span>()</span></span>\n<span class=\"line\"><span>def</span><span> eval_minibatch</span><span>():</span></span>\n<span class=\"line\"><span>    model.eval()</span></span>\n<span class=\"line\"><span>    idx    </span><span>=</span><span> torch.randint(</span><span>0</span><span>, Xtst.size(</span><span>0</span><span>), (</span><span>4096</span><span>,), </span><span>device</span><span>=</span><span>device)</span></span>\n<span class=\"line\"><span>    logits </span><span>=</span><span> model(Xtst[idx])                 </span><span># (B, V)</span></span>\n<span class=\"line\"><span>    y      </span><span>=</span><span> Ytst[idx]                        </span><span># (B,)</span></span>\n<span class=\"line\"><span>    loss   </span><span>=</span><span> F.cross_entropy(logits, y).item()</span></span>\n<span class=\"line\"><span>    acc    </span><span>=</span><span> (logits.argmax(</span><span>1</span><span>) </span><span>==</span><span> y).float().mean().item()  </span><span># [docs](https://pytorch.org/docs/stable/generated/torch.argmax.html)</span></span>\n<span class=\"line\"><span>    model.train()</span></span>\n<span class=\"line\"><span>    return</span><span> loss, acc</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>val_loss, val_acc </span><span>=</span><span> eval_minibatch()</span></span>\n<span class=\"line\"><span>print</span><span>(</span><span>f</span><span>\"val loss: </span><span>{</span><span>val_loss</span><span>:.4f</span><span>}</span><span> | val acc: </span><span>{</span><span>val_acc</span><span>:.3f</span><span>}</span><span>\"</span><span>)</span></span></code></pre>\n<p>With this setup, the model is no longer just a table of counts. It is a trainable system that builds its own internal representations. Even with a fixed 3-character window, we can already see the core ingredients of modern language models at work: embeddings, nonlinear layers, and iterative optimization.</p>\n<hr />\n<h2><strong>8. Sampling &amp; Decoding</strong></h2>\n<p>Training gives us losses and accuracies, but sampling turns those numbers into names. Generation is just sampling: repeatedly draw the next token from the model’s conditional distribution until EOS is reached.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@torch.no_grad</span><span>()</span></span>\n<span class=\"line\"><span>def</span><span> generate</span><span>(n_samples</span><span>=</span><span>10</span><span>, maxlen</span><span>=</span><span>15</span><span>):</span></span>\n<span class=\"line\"><span>    model.eval()</span></span>\n<span class=\"line\"><span>    V </span><span>=</span><span> len</span><span>(itos)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    for</span><span> _ </span><span>in</span><span> range</span><span>(n_samples):</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        ctx </span><span>=</span><span> torch.zeros((</span><span>1</span><span>, block_size), </span><span>dtype</span><span>=</span><span>torch.long, </span><span>device</span><span>=</span><span>device)  </span></span>\n<span class=\"line\"><span>        s </span><span>=</span><span> []</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> _ </span><span>in</span><span> range</span><span>(maxlen):</span></span>\n<span class=\"line\"><span>            logits </span><span>=</span><span> model(ctx)                          </span><span># (1, V) logits</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # convert logits into probabilities</span></span>\n<span class=\"line\"><span>            probs </span><span>=</span><span> F.softmax(logits, </span><span>dim</span><span>=</span><span>1</span><span>)             </span><span># (1, V) [docs](https://pytorch.org/docs/stable/generated/torch.nn.functional.softmax.html)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # sample from the distribution</span></span>\n<span class=\"line\"><span>            next_idx </span><span>=</span><span> torch.multinomial(probs, </span><span>1</span><span>)       </span><span># (1, 1)</span></span>\n<span class=\"line\"><span>            ix </span><span>=</span><span> next_idx.item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            ch </span><span>=</span><span> itos[ix]</span></span>\n<span class=\"line\"><span>            if</span><span> ch </span><span>==</span><span> \"?\"</span><span>:</span></span>\n<span class=\"line\"><span>                break</span></span>\n<span class=\"line\"><span>            s.append(ch)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # slide the window forward</span></span>\n<span class=\"line\"><span>            ctx </span><span>=</span><span> torch.roll(ctx, </span><span>shifts</span><span>=-</span><span>1</span><span>, </span><span>dims</span><span>=</span><span>1</span><span>)     </span><span># still (1, block_size) [docs](https://pytorch.org/docs/stable/generated/torch.roll.html)</span></span>\n<span class=\"line\"><span>            ctx[</span><span>0</span><span>, </span><span>-</span><span>1</span><span>] </span><span>=</span><span> ix</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        print</span><span>(</span><span>\"\"</span><span>.join(s))</span></span></code></pre>\n<p>Generation proceeds by starting with SOS tokens, repeatedly sampling from the <a href=\"https://en.wikipedia.org/wiki/Softmax_function\" rel=\"noopener noreferrer\">softmax</a> distribution, and shifting the context forward until EOS or max length is reached.</p>\n<h3>Example Outputs:</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>askiem</span></span>\n<span class=\"line\"><span>alerindro</span></span>\n<span class=\"line\"><span>richardin</span></span>\n<span class=\"line\"><span>fulayma</span></span>\n<span class=\"line\"><span>marian</span></span>\n<span class=\"line\"><span>jhona</span></span>\n<span class=\"line\"><span>nodou</span></span>\n<span class=\"line\"><span>chris</span></span>\n<span class=\"line\"><span>saurie</span></span>\n<span class=\"line\"><span>nardi</span></span>\n<span class=\"line\"><span>enni</span></span>\n<span class=\"line\"><span>giadan</span></span>\n<span class=\"line\"><span>serena</span></span>\n<span class=\"line\"><span>bessa</span></span>\n<span class=\"line\"><span>emma</span></span></code></pre>\n<p>Some outputs (marian, serena, emma) are plausible Italian names; others (askiem, fulayma) are hybrids that never appeared in training. This balance is exactly what we expect: the model has internalized local character regularities but lacks the longer memory needed for fully natural names. Sampling exposes both strengths and limits: the model generalizes beyond memorization but still lacks long-range structure.</p>\n<hr />\n<h2><strong>9. Analysis</strong></h2>\n<p>With both the count-based bigram model and the neural 3-gram model trained, we can now compare them quantitatively.</p>\n<h3>Quantitative Comparison</h3>\n<p>On the held-out test set:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Bigram test loss</span></span>\n<span class=\"line\"><span>with</span><span> torch.no_grad():</span></span>\n<span class=\"line\"><span>    log_probs </span><span>=</span><span> torch.log(probs[Xtst, Ytst])</span></span>\n<span class=\"line\"><span>    loss_bigram </span><span>=</span><span> -</span><span>log_probs.mean().item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Neural 3-gram test loss </span></span>\n<span class=\"line\"><span>val_loss </span><span>=</span><span> F.cross_entropy(model(Xtst), Ytst).item()</span></span>\n<span class=\"line\"><span>print</span><span>(loss_bigram, val_loss)</span></span></code></pre>\n<p>Results:</p>\n<ul>\n<li>Bigram loss ≈ 2.48</li>\n<li>Neural 3-gram loss ≈ 2.09</li>\n</ul>\n<p>The reduction of ~0.4 bits per character means the trigram model assigns systematically sharper probabilities, leading to fewer mistakes across the sequence. Even small cross-entropy reductions compound over long sequences.</p>\n<h3>The Role of Context</h3>\n<ul>\n<li><strong>Bigram:</strong> memory of one token → captures only immediate adjacency.</li>\n<li><strong>Neural 3-gram:</strong> memory of three tokens → smoother, more realistic local patterns.</li>\n<li><strong>General rule:</strong> longer contexts reduce uncertainty by conditioning on more history, but they demand architectures (and data/compute) that can handle the growth in parameters and estimation complexity.</li>\n</ul>\n<p>In short, language modeling is a trade-off between <strong>expressiveness</strong> (how much structure longer context can capture) and <strong>feasibility</strong> (how much compute and data are required). Modern models (<a href=\"https://en.wikipedia.org/wiki/Recurrent_neural_network\" rel=\"noopener noreferrer\">RNNs</a> → <a href=\"https://en.wikipedia.org/wiki/Transformer_(machine_learning_model)\" rel=\"noopener noreferrer\">Transformers</a>) push this frontier by scaling effective context to entire sequences.</p>\n<hr />\n<h2><strong>10. Conclusion</strong></h2>\n<p>We set out to generate plausible Italian names via next-character prediction. From count-based bigrams to a neural trigram, we made the math explicit (MLE, softmax, cross-entropy) and showed how fixed counts become learnable functions through embeddings and nonlinear layers.</p>\n<h3>Key Lessons</h3>\n<p>At heart, a language model factorizes</p>\n<div class=\"formula\"><code>P(x_1, \\dots, x_n) \\;=\\; \\prod_{t=1}^n P(x_t \\mid x_{&lt;t}),</code></div>\n<p>with effectiveness governed by how much context it conditions on. (<a href=\"https://en.wikipedia.org/wiki/Chain_rule_(probability)\" rel=\"noopener noreferrer\">Chain rule</a>)</p>\n<ul>\n<li><strong>Neural models generalize counts.</strong> With one-hot inputs, an embedding + MLP can reproduce the bigram table; added capacity captures patterns not observed verbatim.</li>\n<li><strong>Empirical signal matters.</strong> Test cross-entropy dropped from ~2.48 to ~2.09 <strong><a href=\"https://en.wikipedia.org/wiki/Nat_(information)\" rel=\"noopener noreferrer\">nats</a></strong>, indicating systematically sharper predictions.</li>\n<li><strong>Context length limits performance.</strong> More history improves coherence but requires more data and compute.</li>\n</ul>\n<h3>Big Picture</h3>\n<p>Language modeling has always fought with context. Classical n-grams blow up combinatorially, while small neural nets manage only modest extensions. Modern models—<a href=\"https://en.wikipedia.org/wiki/Recurrent_neural_network\" rel=\"noopener noreferrer\">RNNs</a> and especially <a href=\"https://en.wikipedia.org/wiki/Transformer_(machine_learning_model)\" rel=\"noopener noreferrer\">Transformers</a>—address this by reusing and sharing parameters, scaling context from a handful of characters to entire documents.</p>\n<p>Next up: <strong><a href=\"https://en.wikipedia.org/wiki/Attention_(machine_learning)\" rel=\"noopener noreferrer\">attention</a></strong>—the mechanism that lets models capture dependencies across any distance without the n-gram blow-up. In the following post we’ll unpack <em>Attention Is All You Need</em> and see how it unlocks the long-range structure behind today’s transformers.</p>","date_published":"2025-09-08T00:00:00.000Z","tags":["NeuralNetworks","Math","Statistics","Python"]},{"id":"https://www.tommasovaccari.com/blog/breaking-sorting-barrier-sssp","url":"https://www.tommasovaccari.com/blog/breaking-sorting-barrier-sssp","title":"Breaking the Sorting Barrier in Single-Source Shortest Paths","summary":"Exploring the ideas behind the new breakthrough paper, breaking the long-standing sorting barrier for single-source shortest paths.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<p>Today I want to explore the breakthrough ideas presented in the paper <strong>Breaking the Sorting Barrier for Directed Single-Source Shortest Paths</strong> . Before diving into those concepts, it’s important to briefly introduce Dijkstra’s algorithm and its purpose.</p>\n<p><img src=\"https://www.tommasovaccari.com/static/router-graph-d070f87f.webp\" alt=\"A simple directed graph illustrating routing\" /></p>\n<h3>1. Introduction: Why SSSP Still Matters</h3>\n<p>In many practical applications, you work with a directed graph <code class=\"formula-inline\">G=(V,E)</code>, where <code class=\"formula-inline\">V</code> represents vertices and <code class=\"formula-inline\">E</code> the edges. Each edge is often associated with a weight (for our purposes, these must be positive—we will see later why this restriction matters). These weights can model:</p>\n<ul>\n<li>Distances</li>\n<li>Costs of traversal</li>\n<li>Latencies</li>\n</ul>\n<p>The fundamental task is to find the shortest path (in terms of total weight) from a given source to all other vertices. This problem is known as the <strong>Single-Source Shortest Path (SSSP)</strong> problem.</p>\n<p>SSSP remains central in many domains, including:</p>\n<ul>\n<li>Routing algorithms in networks</li>\n<li>Networking protocols</li>\n<li>Compiler optimizations</li>\n</ul>\n<p>In 1956, Edsger W. Dijkstra devised an elegant solution to this problem. Three years later, he formally published it in his influential paper <strong>“A Note on Two Problems in Connexion with Graphs”</strong> (1959).</p>\n<p>This contribution was historic: it provided a clean and efficient algorithm for a problem that seemed computationally challenging at the time. Given that computers in the 1950s were extremely limited in speed and memory, Dijkstra’s solution was not just clever but fundamental.</p>\n<p>Dijkstra’s original form of the algorithm became a cornerstone of computer science. Later decades brought refinements—through improved data structures, heuristic methods, and adaptations for massive graphs—that extended its reach. Yet none have surpassed the foundational impact of Dijkstra’s 1959 publication.</p>\n<p>Because of the importance of the SSSP problem, research has continued for decades to improve the time complexity of shortest-path algorithms. And now, after more than sixty years, we have reached a new frontier: breaking the long-standing sorting barrier for directed SSSP.</p>\n<hr />\n<h3>2. Dijkstra’s Algorithm: The Classical Foundation</h3>\n<p>In this section, we review how Dijkstra’s algorithm works at a high level. The goal is simple: compute the shortest path from a given source to every other vertex in the graph.</p>\n<p>Formally, consider a graph <code class=\"formula-inline\">G=(V,E)</code>, where <code class=\"formula-inline\">V</code> is the set of vertices and <code class=\"formula-inline\">E</code> is the set of edges. Each vertex stores a value <code class=\"formula-inline\">w</code>, representing its current best-known distance from the source, and a list of its adjacent vertices.</p>\n<p><strong>Initialization.</strong></p>\n<ul>\n<li>Set the distance of the source vertex to <code class=\"formula-inline\">0</code>.</li>\n<li>Set the distance of every other vertex to <code class=\"formula-inline\">+∞</code>.</li>\n<li>Maintain a set <code class=\"formula-inline\">S</code> of processed vertices (conceptually useful, though not always explicit in implementations).</li>\n<li>Maintain a priority structure <code class=\"formula-inline\">Q</code> containing all vertices, keyed by their distance <code class=\"formula-inline\">w</code>.</li>\n</ul>\n<p><strong>Main loop.</strong></p>\n<p>While <code class=\"formula-inline\">Q</code> is not empty:</p>\n<ol>\n<li>Extract from <code class=\"formula-inline\">Q</code> the vertex <code class=\"formula-inline\">u</code> with the smallest <code class=\"formula-inline\">w</code>.</li>\n<li>Add <code class=\"formula-inline\">u</code> to <code class=\"formula-inline\">S</code>.</li>\n<li>For each adjacent vertex <code class=\"formula-inline\">v</code> of <code class=\"formula-inline\">u</code>, perform a relaxation step: update <code class=\"formula-inline\">v</code>’s distance if the path through <code class=\"formula-inline\">u</code> is shorter than its current value.</li>\n</ol>\n<p>Relaxation means checking whether:</p>\n<p><code class=\"formula-inline\">w(v) &gt; w(u) + weight(u,v)</code> and, if so, updating <code class=\"formula-inline\">w(v)</code>.</p>\n<p><strong>Pseudocode:</strong></p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>Dijkstra(G,source):</span></span>\n<span class=\"line\"><span>    Initialize</span><span>-</span><span>Graph(G, source)</span></span>\n<span class=\"line\"><span>    S </span><span>=</span><span> EmptyList()</span></span>\n<span class=\"line\"><span>    Q </span><span>=</span><span> List(</span><span>all</span><span> vertices </span><span>in</span><span> G)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    while</span><span> Q </span><span>is</span><span> not</span><span> empty:</span></span>\n<span class=\"line\"><span>        u </span><span>=</span><span> ExtractMin(Q)</span></span>\n<span class=\"line\"><span>        S.append(u)</span></span>\n<span class=\"line\"><span>        for</span><span> each vertex v adjacent to u:</span></span>\n<span class=\"line\"><span>            Relax(u, v)</span></span>\n<span class=\"line\"></span></code></pre>\n<p>Because the algorithm always chooses the vertex with the smallest distance, it is classified as a greedy algorithm. Greedy methods do not always guarantee correctness in general, but in this case correctness can be formally proved using induction on the size of <code class=\"formula-inline\">S</code>.</p>\n<p><strong>Complexity.</strong>\nIn Dijkstra’s original implementation, the priority structure <code class=\"formula-inline\">Q</code> was simply an array. Extracting the minimum then required scanning the entire array, which costs <code class=\"formula-inline\">O(V)</code>  per operation. Since every vertex must be extracted once, this contributes <code class=\"formula-inline\">O(V^2)</code></p>\n<p>The inner relaxation loop processes each edge at most once, costing <code class=\"formula-inline\">O(E)</code>.</p>\n<p>Thus, the overall complexity is: <code class=\"formula-inline\">O(V^2 + E)</code></p>\n<hr />\n<h3>3. Variants and Data Structures: Pushing the Boundaries</h3>\n<p>The bottleneck in Dijkstra’s original implementation is the Extract-Min operation: finding the vertex with the smallest tentative distance. Using a simple array makes this <code class=\"formula-inline\">O(V)</code> which quickly dominates runtime on larger graphs.</p>\n<p>A major breakthrough came with the introduction of binary heaps as the priority queue for <code class=\"formula-inline\">Q</code> (widely popularized in the 1970s). With this change:</p>\n<ul>\n<li>Extract-Min takes <code class=\"formula-inline\">O(\\log V)</code>.</li>\n<li>Each edge relaxation may trigger a Decrease-Key, also <code class=\"formula-inline\">O(\\log V)</code>.</li>\n<li>The overall running time becomes <code class=\"formula-inline\">O(E \\log V)</code> (often written as <code class=\"formula-inline\">O((V+E)\\log V)</code>), a dramatic improvement for sparse graphs.</li>\n</ul>\n<p>On the theoretical side, Fibonacci heaps (Fredman &amp; Tarjan, 1984) pushed the asymptotics even further: <code class=\"formula-inline\">O(E + V \\log V)</code>. Here, Decrease-Key runs in amortized <code class=\"formula-inline\">O(1)</code>. While elegant in theory, Fibonacci heaps are rarely used in practice because of pointer-heavy structures, large constant factors, and poor cache performance. This highlights a recurring lesson in algorithms: asymptotic optimality does not always translate into real-world efficiency.</p>\n<p>For graphs with bounded integer weights, an alternative approach is <strong>Dial’s algorithm</strong> (1969). It uses an array of buckets indexed by distance values, achieving: <code class=\"formula-inline\">O(V + E + C)</code> where <code class=\"formula-inline\">C</code> is the maximum edge weight. On road networks or latency graphs with small integer costs, this method can be extremely efficient.</p>\n<p>Other heap structures — pairing heaps, radix heaps, relaxed heaps — have been proposed over the years, each balancing theoretical guarantees and practical constants in different ways. Yet despite decades of improvements, progress has always seemed tethered to the same barrier: the cost of sorting-like operations lurking inside priority queue management.</p>\n<hr />\n<h3>4. The New Paper: <em>Breaking the Sorting Barrier for Single-Source Shortest Paths</em></h3>\n<p>The 2025 paper <em>Breaking the Sorting Barrier for Directed Single-Source Shortest Paths</em> by Ran et al. presents the first deterministic algorithm that beats the classical:</p>\n<div class=\"formula\"><code>O(|E| + |V| \\log |V|)</code></div>\n<p>bound for directed SSSP with nonnegative real weights in the comparison–addition model.\nIt is important to state explicitly that the result holds in this model, since it rules out shortcuts that rely on specialized data structures or RAM-model tricks.</p>\n<p>Their result achieves:</p>\n<div class=\"formula\"><code>O(|E| \\, \\log^{2/3} |V|).</code></div>\n<p>This is not a wholesale replacement for Dijkstra’s algorithm. If you require the full ordering of vertices by distance, Dijkstra remains optimal. If you only need the distance values themselves, the long-assumed “sorting barrier” is not inherent.</p>\n<p>The analysis assumes two standard technical moves: (i) a degree-reduction transformation so every vertex has constant in/out degree, and (ii) a lexicographic tie-breaking rule so that every path has a unique length. Both are common but necessary for the recursion to work cleanly.</p>\n<h4>Key idea: shrinking the frontier</h4>\n<p>The bottleneck in Dijkstra’s algorithm is maintaining a strict total order over a frontier of tentative vertices.\nAt times this frontier can be size <code class=\"formula-inline\">\\Theta(|V|)</code>, forcing <code class=\"formula-inline\">\\Omega(\\log |V|)</code> work for each extract-min.\nThat’s where the extra <code class=\"formula-inline\">\\log |V|</code> factor comes from.</p>\n<p>The new algorithm avoids this by working in bands <code class=\"formula-inline\">[b,B)</code>.\nSuppose all vertices with <code class=\"formula-inline\">d(v) &lt; b</code> are complete, and let <code class=\"formula-inline\">S</code> be the current frontier of vertices with tentative distances in <code class=\"formula-inline\">[b,B)</code>.\nDefine the covered set:</p>\n<div class=\"formula\"><code>\\tilde{U} = \\{ v \\in V : d(v) &lt; B \\ \\text{and the shortest path } s \\to v \\text{ passes through some } u \\in S \\}.</code></div>\n<p>Every unfinished vertex with <code class=\"formula-inline\">d(v) &lt; B</code> lies in <code class=\"formula-inline\">\\tilde{U}</code>.</p>\n<p>Now, depending on the size of <code class=\"formula-inline\">\\tilde{U}</code> relative to <code class=\"formula-inline\">|S|</code>, the algorithm takes one of two actions, controlled by a parameter <code class=\"formula-inline\">k</code>:</p>\n<ol>\n<li><strong>Bulk progress.</strong> If <code class=\"formula-inline\">|\\tilde{U}| \\geq k \\cdot |S|</code>, a bounded multi-source shortest path (BMSSP) call finalizes all vertices in <code class=\"formula-inline\">\\tilde{U}</code> with <code class=\"formula-inline\">d(v) &lt; B</code> in one shot.</li>\n<li><strong>Frontier reduction.</strong> Otherwise, perform <code class=\"formula-inline\">k</code> rounds of relaxations from <code class=\"formula-inline\">S</code>. Vertices whose shortest paths use fewer than <code class=\"formula-inline\">k</code> frontier vertices are finalized.\nThe remaining ones each select a pivot in <code class=\"formula-inline\">S</code>; the number of pivots is at most <code class=\"formula-inline\">|\\tilde{U}| / k</code>.\nThis shrinks the frontier by a factor of about <code class=\"formula-inline\">k</code>.</li>\n</ol>\n<p>In either case, progress is guaranteed: either a large batch of vertices is finalized, or the frontier becomes much smaller.</p>\n<h4>Parameter balancing and complexity</h4>\n<p>The performance of the recursion depends on two parameters:</p>\n<div class=\"formula\"><code>k = \\lfloor \\log^{1/3} V \\rfloor, \\qquad\nt = \\lfloor \\log^{2/3} V \\rfloor.</code></div>\n<p>The parameter <code class=\"formula-inline\">k</code> determines how aggressively the frontier can be reduced in the pivoting step. In the worst case, the number of pivots introduced in one round is bounded by <code class=\"formula-inline\">|\\widetilde{U}|/k</code>, which contracts the frontier by a factor of <code class=\"formula-inline\">\\Theta(k)</code>. The parameter <code class=\"formula-inline\">t</code> specifies the band width <code class=\"formula-inline\">[b,B)</code>, and hence the distance threshold within which the bounded multi-source shortest path (BMSSP) subroutine operates.</p>\n<p>The recursion proceeds in layers: each layer either finalizes a large fraction of <code class=\"formula-inline\">\\widetilde{U}</code> via BMSSP or reduces the frontier by a factor of about <code class=\"formula-inline\">k</code>. Since the frontier shrinks by <code class=\"formula-inline\">\\Theta(k)</code> in a reduction step, the number of recursive levels is at most</p>\n<div class=\"formula\"><code>\\frac{\\log V}{\\log k} = O\\!\\left(\\frac{\\log V}{\\log^{1/3} V}\\right) = O(\\log^{2/3} V).</code></div>\n<p>Within each level, every edge is relaxed at most once, and the per-vertex overhead due to frontier reduction is bounded by</p>\n<div class=\"formula\"><code>\\frac{\\log V}{k} = O(\\log^{2/3} V).</code></div>\n<p>Combining these observations, the overall running time is</p>\n<div class=\"formula\"><code>O(|E| \\log^{2/3} V).</code></div>\n<p>The balance between <code class=\"formula-inline\">k</code> and <code class=\"formula-inline\">t</code> is crucial: larger <code class=\"formula-inline\">k</code> accelerates frontier reduction but increases per-level cost, while larger <code class=\"formula-inline\">t</code> decreases recursion depth but inflates the band width and the work of BMSSP. The chosen values <code class=\"formula-inline\">k = \\log^{1/3} V</code> and <code class=\"formula-inline\">t = \\log^{2/3} V</code> minimize the total overhead and yield the <code class=\"formula-inline\">\\log^{2/3} V</code> factor.</p>\n<h4>Runtime comparison</h4>\n<table><thead><tr><th>Algorithm</th><th>Time complexity</th><th>Applicability</th></tr></thead><tbody><tr><td>Dijkstra (array)</td><td><code>O(V^2 + E)</code></td><td>Historical baseline; easy to implement but quadratic on large graphs</td></tr><tr><td>Dijkstra (binary heap)</td><td><code>O(E \\log V)</code></td><td>Standard practical choice; efficient for sparse graphs and widely deployed</td></tr><tr><td>Ran et al. (2025)</td><td><code>O(E \\log^{2/3} V)</code></td><td>First deterministic algorithm to beat the <code>O(E \\log V)</code> bound in the comparison–addition model</td></tr></tbody></table>\n<p>The 2025 result is not a practical replacement for heap-based Dijkstra. Its significance lies in theory: it shows that the <code class=\"formula-inline\">\\log n</code> factor in SSSP is not inherent. The breakthrough is stated in the comparison–addition model, where algorithms are only allowed to compare and add edge weights. This model is the standard baseline for analyzing general-weight graph algorithms, since it rules out shortcuts that depend on specialized word-RAM techniques or bounded integers. Citing it makes clear that the improvement holds in the broadest, most widely accepted setting—directly comparable to Dijkstra’s classical <code class=\"formula-inline\">O(E \\log V)</code> bound.</p>\n<hr />\n<h3>5. Conclusion and Sources</h3>\n<p>The single-source shortest path problem has been studied for almost seventy years, from Dijkstra’s elegant <code class=\"formula-inline\">O(V^2+E)</code> algorithm to heap-based improvements and practical variants like Dial’s method. For decades, every faster deterministic algorithm seemed shackled by the same bottleneck: the cost of maintaining a sorted frontier.</p>\n<p>Ran et al. (2025) show that this barrier is not inherent. By sidestepping the need for a global order and instead settling vertices in controlled batches, their algorithm replaces the <code class=\"formula-inline\">\\log |V|</code> overhead with <code class=\"formula-inline\">\\log^{2/3} |V|</code>. This marks the first deterministic improvement to Dijkstra’s runtime in the comparison–addition model.</p>\n<p>The breakthrough is not just about speed—it’s about perspective. Shortest paths can be computed without ever maintaining a total order of tentative distances, reshaping how we think about the greedy structure of SSSP and opening doors to further refinements.</p>\n<p>I hope you enjoyed the post and that it gave you a grasp of SSSP algorithms!</p>\n<h4>Sources and Further Reading</h4>\n<ul>\n<li><strong>Edsger W. Dijkstra (1959).</strong> <a href=\"https://doi.org/10.1007/BF01386390\" rel=\"noopener noreferrer\">A Note on Two Problems in Connexion with Graphs</a>. Numerische Mathematik, 1:269–271. The original publication of Dijkstra’s algorithm.</li>\n<li><strong>Wikipedia:</strong> <a href=\"https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm\" rel=\"noopener noreferrer\">Dijkstra’s Algorithm</a>. A solid high-level overview with links to variants.</li>\n<li><strong>Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein (2009).</strong> <a href=\"https://mitpress.mit.edu/9780262033848/introduction-to-algorithms/\" rel=\"noopener noreferrer\">Introduction to Algorithms</a>, 3rd ed.</li>\n<li><strong>Steven S. Skiena (2008).</strong> <a href=\"https://link.springer.com/book/10.1007/978-1-84800-070-4\" rel=\"noopener noreferrer\">The Algorithm Design Manual</a>, 2nd ed. Emphasizes practical aspects of graph algorithms.</li>\n<li><strong>Ran et al. (2025).</strong> <a href=\"https://arxiv.org/abs/2504.17033\" rel=\"noopener noreferrer\">Breaking the Sorting Barrier for Directed Single-Source Shortest Paths</a>. The breakthrough paper itself—technical but rewarding for anyone who wants to see the new algorithm in detail.</li>\n</ul>","date_published":"2025-09-01T00:00:00.000Z","tags":["Dijkstra","SSSP","Graph Algorithms","Complexity Theory"]},{"id":"https://www.tommasovaccari.com/blog/linear-regression","url":"https://www.tommasovaccari.com/blog/linear-regression","title":"Neural Networks: Linear Regression","summary":"From math to code: build linear regression from scratch and see how it connects to neural networks.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<p>In this second post, we will take another step forward. We’ll dive into the concept of linear regression, explore its connection to neural networks, and demonstrate how to build a model from scratch. This approach will help us gain a deeper understanding of the core concepts behind both linear regression and neural networks.</p>\n<p>Prerequisites: PyTorch, NumPy, and Matplotlib installed (for plotting and numerical operations).</p>\n<h2>Linear Regression</h2>\n<h3>Model and Loss function</h3>\n<p>Before starting, we need to define some terminology. When we want to predict a value, we call it a label or target. Each label is associated with its own features. The model makes predictions based solely on these features. Therefore, we need to refine the model to accurately predict the correct label based on the provided input features.</p>\n<p>We want to predict a target, <code class=\"formula-inline\">\\hat{y}</code>, based on a set of features grouped in a vector called <code class=\"formula-inline\">X</code>. To make the prediction, we need to find the weight vector <code class=\"formula-inline\">W</code> and the bias <code class=\"formula-inline\">B</code> that give us the most accurate predictions. This relationship can be expressed using the dot product:</p>\n<div class=\"formula\"><code>\\hat{y} = W \\cdot X + b</code></div>\n<p>Let <code class=\"formula-inline\">X</code> be the feature matrix (rows are samples, columns are features). Then the model’s predictions for all samples can be written compactly as a vector:</p>\n<div class=\"formula\"><code>\\hat{y} = X \\cdot W + b</code></div>\n<p>Now we need to define a loss function to evaluate the model. The most common is the squared error, where <code class=\"formula-inline\">\\hat{y}_i</code> is the prediction and <code class=\"formula-inline\">y_i</code> is the corresponding true label.</p>\n<div class=\"formula\"><code>L_i(W, b)= \\frac{1}{2} (\\hat{y}_i-y_i)^2</code></div>\n<p>We can observe that the loss is a function of weight and bias.</p>\n<p>Now we can extend the evaluation of the loss along all the predictions, averaging it to obtain an intuitive idea of how our model is performing.</p>\n<div class=\"formula\"><code>L_i (W,B) =\\frac{1}{n} \\sum_{i=1}^{n} L_i(W,B) = \\frac{1}{n} \\sum_{i=1}^{n} \\frac{1}{2} (X^{(i)} \\cdot W + b - y^{(i)})^2</code></div>\n<p>We have to keep in mind our goal: we want to find W and B that give us the minimum value of loss along all the predictions.</p>\n<h3>Minibatch Stochastic Gradient Descent</h3>\n<p>The key to optimizing a model and improving its predictions is to iteratively update the weights by modifying them in the opposite direction of the gradient of the loss function. This process is known as gradient descent. Although it may seem simple at first glance, it is the foundation of many advanced techniques in machine learning and plays a central role in training modern models.</p>\n<p>To optimize the process, we do not update the model using the entire dataset at once. Instead, we randomly select smaller subsets of the data, called batches. The model is then optimized iteratively by performing updates on these batches, which makes the process more efficient and scalable, especially for large datasets. This approach is commonly referred to as mini-batch gradient descent.</p>\n<p>To keep it simple we can break down this process into four steps:</p>\n<ol>\n<li>Batch Selection:\nRandomly choose a small subset of training data. This approach balances computational efficiency with learning effectiveness. Instead of processing the entire dataset, which would be slow and memory-intensive, we sample a representative mini-batch that captures the overall data characteristics.</li>\n<li>Loss Calculation:\nMeasure how far the model's predictions are from the true values for each example in the batch. Compute the average loss, which serves as a performance metric. This average loss quantifies the model's current error, providing a clear signal about how well (or poorly) the model is performing on this particular set of examples.</li>\n<li>Gradient Computation: Calculate the derivative of the loss with respect to each model parameter. This gradient acts like a compass, pointing to the direction that would most quickly increase the loss. By understanding how each weight contributes to the model's error, we can intelligently adjust the model's internal representation to improve its predictive capabilities.</li>\n<li>Parameter Update: Move the model's parameters in the opposite direction of the gradient, scaled by a small learning rate. This is akin to taking careful steps down a complex landscape, where each step aims to reduce the overall error. The learning rate determines the size of these steps – too large, and you might overshoot the optimal solution; too small, and progress becomes painfully slow.</li>\n</ol>\n<p>If we want to express this in formulas we have:</p>\n<ol>\n<li>The weights update:</li>\n</ol>\n<div class=\"formula\"><code>\\mathbf{w} \\gets \\mathbf{w} - \\frac{\\eta}{|\\mathcal{B}|} \\sum_{i \\in \\mathcal{B}_t} \\frac{\\partial L^{(i)} (\\mathbf{w}, b)}{\\partial \\mathbf{w}} = \\mathbf{w} - \\frac{\\eta}{|\\mathcal{B}|} \\sum_{i \\in \\mathcal{B}_t} \\left( \\mathbf{x}^{(i)} \\cdot \\mathbf{w} + b - y^{(i)} \\right) \\mathbf{x^{(i)}}.</code></div>\n<ol>\n<li>The Bias Update:</li>\n</ol>\n<div class=\"formula\"><code>b \\gets b - \\frac{\\eta}{|\\mathcal{B}|} \\sum_{i \\in \\mathcal{B}_t} \\frac{\\partial L^{(i)} (\\mathbf{w}, b)}{\\partial b} \n= b - \\frac{\\eta}{|\\mathcal{B}|} \\sum_{i \\in \\mathcal{B}_t} \\left( \\mathbf{x}^{(i)} \\cdot \\mathbf{w} + b - y^{(i)} \\right).</code></div>\n<h3>Linear Regression as a Neural Network</h3>\n<p>While linear models are not sufficiently rich to express complex relationships between features, we can introduce neural networks to obtain a more expressive model. Nevertheless, we can also view a linear model as a neural network where every input feature corresponds to a neuron with its own weight and bias.</p>\n<h2>Implementation using object-oriented design</h2>\n<p>To gain a deeper understanding of how a model is created and trained, we aim to implement the classes and methods from scratch. We are following this approach because, in my opinion, using machine learning libraries like PyTorch directly does not provide a comprehensive understanding of what happens under the hood.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>%</span><span>matplotlib inline</span></span>\n<span class=\"line\"><span>import</span><span> matplotlib.pyplot </span><span>as</span><span> plt</span></span>\n<span class=\"line\"><span>import</span><span> numpy </span><span>as</span><span> np</span></span>\n<span class=\"line\"><span>import</span><span> torch</span></span></code></pre>\n<p>Now we are ready to implement our model for linear regression from scratch. We need:</p>\n<ol>\n<li>The model</li>\n<li>The loss function</li>\n<li>The optimization algorithm</li>\n<li>The training function</li>\n</ol>\n<h3>Building the model</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> LinearRegressionModel</span><span>:</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, num_inputs, learning_rate, sigma</span><span>=</span><span>0.01</span><span>):</span></span>\n<span class=\"line\"><span>            \"\"\"</span></span>\n<span class=\"line\"><span>            Initialize the model parameters.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            Args:</span></span>\n<span class=\"line\"><span>            - num_inputs (int): Number of input features.</span></span>\n<span class=\"line\"><span>            - learning_rate (float): Learning rate for gradient descent.</span></span>\n<span class=\"line\"><span>            - sigma (float): Standard deviation for initializing weights.</span></span>\n<span class=\"line\"><span>            \"\"\"</span></span>\n<span class=\"line\"><span>            self</span><span>.num_inputs </span><span>=</span><span> num_inputs</span></span>\n<span class=\"line\"><span>            self</span><span>.learning_rate </span><span>=</span><span> learning_rate</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>            # Initialize weights and bias</span></span>\n<span class=\"line\"><span>            self</span><span>.w </span><span>=</span><span> torch.normal(</span><span>mean</span><span>=</span><span>0.0</span><span>, </span><span>std</span><span>=</span><span>sigma, </span><span>size</span><span>=</span><span>(num_inputs, </span><span>1</span><span>), </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>            self</span><span>.b </span><span>=</span><span> torch.zeros(</span><span>1</span><span>, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span></code></pre>\n<p>Here we have created a class that contains the weights and bias. Additionally, we have introduced the hyperparameters, which are typically user-defined and used to adjust various aspects during the training phase. The weights are sampled from a <a href=\"https://en.wikipedia.org/wiki/Normal_distribution\" rel=\"noopener noreferrer\">normal distribution with a mean of 0 and a standard deviation of 1</a>, while the bias is initialized to zero.</p>\n<p>Now we can add the method to obtain the forward pass:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@add_to_class</span><span>(LinearRegressionModel)</span></span>\n<span class=\"line\"><span>def</span><span> forward</span><span>(self, X):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Compute the forward pass: y = Xw + b.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - X (torch.Tensor): Input tensor of shape (batch_size, num_inputs).</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - torch.Tensor: Predicted values of shape (batch_size, 1).</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        return</span><span> torch.matmul(X, </span><span>self</span><span>.w) </span><span>+</span><span> self</span><span>.b</span></span></code></pre>\n<h3>Building the loss function</h3>\n<p>Now we add the method to calculate the loss for a single batch of examples by using the squared loss function and then returning the mean.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@add_to_class</span><span>(LinearRegressionModel)</span></span>\n<span class=\"line\"><span>def</span><span> compute_loss</span><span>(self, y_pred, y_true):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Compute Mean Squared Error loss.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - y_pred (torch.Tensor): Predicted values.</span></span>\n<span class=\"line\"><span>        - y_true (torch.Tensor): True values.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - torch.Tensor: Scalar loss value.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        return</span><span> 0.5</span><span> *</span><span> ((y_pred </span><span>-</span><span> y_true) </span><span>**</span><span> 2</span><span>).mean()</span></span></code></pre>\n<h3>Building the optimization algorithm</h3>\n<p>This is the fundamental part of our model—the algorithm that allows us to improve its predictions. We are going to implement Stochastic Gradient Descent (SGD), as discussed earlier.</p>\n<p>The steps we want to follow are:</p>\n<ol>\n<li>Randomly select a batch from the training set.</li>\n<li>Make predictions using the selected batch.</li>\n<li>Compute the loss of the predictions.</li>\n<li>Calculate the gradient of the loss with respect to the weights and bias.</li>\n<li>Update the parameters (weights and bias) using the learning rate and computed gradients.</li>\n</ol>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@add_to_class</span><span>(LinearRegressionModel)</span></span>\n<span class=\"line\"><span>def</span><span> update_parameters</span><span>(self):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Update the model parameters using gradient descent.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        with</span><span> torch.no_grad():</span></span>\n<span class=\"line\"><span>            self</span><span>.w </span><span>-=</span><span> self</span><span>.learning_rate </span><span>*</span><span> self</span><span>.w.grad</span></span>\n<span class=\"line\"><span>            self</span><span>.b </span><span>-=</span><span> self</span><span>.learning_rate </span><span>*</span><span> self</span><span>.b.grad</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # Manually zero the gradients</span></span>\n<span class=\"line\"><span>            self</span><span>.w.grad.zero_()</span></span>\n<span class=\"line\"><span>            self</span><span>.b.grad.zero_()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>def</span><span> train_step</span><span>(self, X, y, batch_size):</span></span>\n<span class=\"line\"><span>    \"\"\"</span></span>\n<span class=\"line\"><span>    Perform a single training step.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    Args:</span></span>\n<span class=\"line\"><span>    - X (torch.Tensor): Input data of shape (num_samples, num_inputs).</span></span>\n<span class=\"line\"><span>    - y (torch.Tensor): Target data of shape (num_samples, 1).</span></span>\n<span class=\"line\"><span>    - batch_size (int): Number of samples per batch.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    Returns:</span></span>\n<span class=\"line\"><span>    - float: Loss value for the batch.</span></span>\n<span class=\"line\"><span>    \"\"\"</span></span>\n<span class=\"line\"><span>    # Sample a random batch</span></span>\n<span class=\"line\"><span>    num_samples </span><span>=</span><span> X.shape[</span><span>0</span><span>]</span></span>\n<span class=\"line\"><span>    indices </span><span>=</span><span> torch.randint(</span><span>0</span><span>, num_samples, (batch_size,))</span></span>\n<span class=\"line\"><span>    X_batch </span><span>=</span><span> X[indices]</span></span>\n<span class=\"line\"><span>    y_batch </span><span>=</span><span> y[indices]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # Forward pass</span></span>\n<span class=\"line\"><span>    y_pred </span><span>=</span><span> self</span><span>.forward(X_batch)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # Compute loss</span></span>\n<span class=\"line\"><span>    loss </span><span>=</span><span> self</span><span>.compute_loss(y_pred, y_batch)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # Backward pass</span></span>\n<span class=\"line\"><span>    loss.backward()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # Update parameters</span></span>\n<span class=\"line\"><span>    self</span><span>.update_parameters()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # Return the loss value as a scalar</span></span>\n<span class=\"line\"><span>    return</span><span> loss.item()</span></span>\n<span class=\"line\"></span></code></pre>\n<h3>Building the training method</h3>\n<p>The last step to complete our model is to implement a method that allows us to train it. It would be useful to visualize how the weights, bias, and learning rate change throughout the training process, so we can also add a method to plot this information.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>@add_to_class</span><span>(LinearRegressionModel)</span></span>\n<span class=\"line\"><span>def</span><span> train</span><span>(self, X, y, epochs, batch_size):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Train the model over multiple epochs.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - X (torch.Tensor): Input data of shape (num_samples, num_inputs).</span></span>\n<span class=\"line\"><span>        - y (torch.Tensor): Target data of shape (num_samples, 1).</span></span>\n<span class=\"line\"><span>        - epochs (int): Number of training epochs.</span></span>\n<span class=\"line\"><span>        - batch_size (int): Number of samples per batch.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - list: List of loss values for each epoch.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        losses </span><span>=</span><span> []</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> epoch </span><span>in</span><span> range</span><span>(epochs):</span></span>\n<span class=\"line\"><span>            # Perform a training step and compute the average loss for the epoch</span></span>\n<span class=\"line\"><span>            loss </span><span>=</span><span> self</span><span>.train_step(X, y, batch_size)</span></span>\n<span class=\"line\"><span>            losses.append(loss)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # Print progress every 10 epochs</span></span>\n<span class=\"line\"><span>            if</span><span> epoch </span><span>%</span><span> 10</span><span> ==</span><span> 0</span><span>:</span></span>\n<span class=\"line\"><span>                print</span><span>(</span><span>f</span><span>\"Epoch </span><span>{</span><span>epoch</span><span>}</span><span>, Loss: </span><span>{</span><span>loss</span><span>:.6f</span><span>}</span><span>\"</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        return</span><span> losses</span></span>\n<span class=\"line\"><span>  </span></span></code></pre>\n<h2>Testing the model</h2>\n<p>Now that the class is built from scratch, we can proceed with testing it to verify if everything works correctly. Testing the model typically involves the following steps:</p>\n<ol>\n<li>Creating synthetic data (or retrieving real data)</li>\n<li>Instantiating the model</li>\n<li>Training the model</li>\n<li>Testing the model on the evaluation set</li>\n</ol>\n<p>Now, we initialize a vector to represent the weights that our model needs to learn from the data during the training process, and we follow the same process for the bias.\nThen we compute the target that we are going to use, with the features to train our model.\nWe can also adjust hyperparameters as needed.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Define hyperparameters</span></span>\n<span class=\"line\"><span>num_samples </span><span>=</span><span> 4000</span></span>\n<span class=\"line\"><span>num_inputs </span><span>=</span><span> 2000</span></span>\n<span class=\"line\"><span>learning_rate </span><span>=</span><span> 0.01</span></span>\n<span class=\"line\"><span>epochs </span><span>=</span><span> 2000</span></span>\n<span class=\"line\"><span>batch_size </span><span>=</span><span> 64</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span># Generate synthetic data</span></span>\n<span class=\"line\"><span>true_w </span><span>=</span><span> torch.randn((num_inputs, </span><span>1</span><span>))</span></span>\n<span class=\"line\"><span>true_b </span><span>=</span><span> torch.rand((</span><span>1</span><span>,</span><span>1</span><span>))</span></span>\n<span class=\"line\"><span>X </span><span>=</span><span> torch.randn((num_samples, num_inputs))</span></span>\n<span class=\"line\"><span>y </span><span>=</span><span> torch.matmul(X, true_w) </span><span>+</span><span> true_b </span><span>+</span><span> torch.randn((num_samples, </span><span>1</span><span>)) </span><span>*</span><span> 0.01</span></span></code></pre>\n<h4>Define hyperparameters:</h4>\n<ul>\n<li><strong>num_samples</strong>: The number of data samples to generate.</li>\n<li><strong>num_inputs</strong>: The number of input features for each sample.</li>\n<li><strong>learning_rate</strong>: The rate at which the model learns during training.</li>\n<li><strong>epochs</strong>: The number of times the entire dataset is passed through the model during training.</li>\n<li><strong>batch_size</strong>: The number of samples processed before the model's internal parameters are updated.</li>\n</ul>\n<h4>Generate synthetic data:</h4>\n<ul>\n<li><strong>true_w</strong>: Randomly generated weights for the input features.</li>\n<li><strong>true_b</strong>: Randomly generated bias term.</li>\n<li><strong>X</strong>: Randomly generated input data with <code>num_samples</code> rows and <code>num_inputs</code> columns.</li>\n<li><strong>y</strong>: The target values calculated by multiplying <code>X</code> with <code>true_w</code>, adding <code>true_b</code>, and adding a small amount of random noise to simulate real-world data.</li>\n</ul>\n<p>Now we can look at the complete class with all the methods:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> LinearRegressionModel</span><span>:</span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, num_inputs, learning_rate, sigma</span><span>=</span><span>0.01</span><span>):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Initialize the model parameters.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - num_inputs (int): Number of input features.</span></span>\n<span class=\"line\"><span>        - learning_rate (float): Learning rate for gradient descent.</span></span>\n<span class=\"line\"><span>        - sigma (float): Standard deviation for initializing weights.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        self</span><span>.num_inputs </span><span>=</span><span> num_inputs</span></span>\n<span class=\"line\"><span>        self</span><span>.learning_rate </span><span>=</span><span> learning_rate</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # Initialize weights and bias</span></span>\n<span class=\"line\"><span>        self</span><span>.w </span><span>=</span><span> torch.normal(</span><span>mean</span><span>=</span><span>0.0</span><span>, </span><span>std</span><span>=</span><span>sigma, </span><span>size</span><span>=</span><span>(num_inputs, </span><span>1</span><span>), </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.b </span><span>=</span><span> torch.zeros(</span><span>1</span><span>, </span><span>requires_grad</span><span>=</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>        self</span><span>.losses </span><span>=</span><span> []</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> forward</span><span>(self, X):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Compute the forward pass: y = Xw + b.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - X (torch.Tensor): Input tensor of shape (batch_size, num_inputs).</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - torch.Tensor: Predicted values of shape (batch_size, 1).</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        return</span><span> torch.matmul(X, </span><span>self</span><span>.w) </span><span>+</span><span> self</span><span>.b</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> compute_loss</span><span>(self, y_pred, y_true):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Compute Mean Squared Error loss.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - y_pred (torch.Tensor): Predicted values.</span></span>\n<span class=\"line\"><span>        - y_true (torch.Tensor): True values.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - torch.Tensor: Scalar loss value.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        return</span><span> 0.5</span><span> *</span><span> ((y_pred </span><span>-</span><span> y_true) </span><span>**</span><span> 2</span><span>).mean()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> update_parameters</span><span>(self):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Update the model parameters using gradient descent.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        with</span><span> torch.no_grad():</span></span>\n<span class=\"line\"><span>            self</span><span>.w </span><span>-=</span><span> self</span><span>.learning_rate </span><span>*</span><span> self</span><span>.w.grad</span></span>\n<span class=\"line\"><span>            self</span><span>.b </span><span>-=</span><span> self</span><span>.learning_rate </span><span>*</span><span> self</span><span>.b.grad</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            # Manually zero the gradients</span></span>\n<span class=\"line\"><span>            self</span><span>.w.grad.zero_()</span></span>\n<span class=\"line\"><span>            self</span><span>.b.grad.zero_()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> train_step</span><span>(self, X, y, batch_size):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Perform a single training step.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - X (torch.Tensor): Input data of shape (num_samples, num_inputs).</span></span>\n<span class=\"line\"><span>        - y (torch.Tensor): Target data of shape (num_samples, 1).</span></span>\n<span class=\"line\"><span>        - batch_size (int): Number of samples per batch.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - float: Loss value for the batch.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        # Sample a random batch</span></span>\n<span class=\"line\"><span>        num_samples </span><span>=</span><span> X.shape[</span><span>0</span><span>]</span></span>\n<span class=\"line\"><span>        indices </span><span>=</span><span> torch.randint(</span><span>0</span><span>, num_samples, (batch_size,))</span></span>\n<span class=\"line\"><span>        X_batch </span><span>=</span><span> X[indices]</span></span>\n<span class=\"line\"><span>        y_batch </span><span>=</span><span> y[indices]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # Forward pass</span></span>\n<span class=\"line\"><span>        y_pred </span><span>=</span><span> self</span><span>.forward(X_batch)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # Compute loss</span></span>\n<span class=\"line\"><span>        loss </span><span>=</span><span> self</span><span>.compute_loss(y_pred, y_batch)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # Backward pass</span></span>\n<span class=\"line\"><span>        loss.backward()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # Update parameters</span></span>\n<span class=\"line\"><span>        self</span><span>.update_parameters()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        # Return the loss value as a scalar</span></span>\n<span class=\"line\"><span>        return</span><span> loss.item()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> train</span><span>(self, X, y, epochs, batch_size):</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>        Train the model over multiple epochs.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        Args:</span></span>\n<span class=\"line\"><span>        - X (torch.Tensor): Input data of shape (num_samples, num_inputs).</span></span>\n<span class=\"line\"><span>        - y (torch.Tensor): Target data of shape (num_samples, 1).</span></span>\n<span class=\"line\"><span>        - epochs (int): Number of training epochs.</span></span>\n<span class=\"line\"><span>        - batch_size (int): Number of samples per batch.</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        Returns:</span></span>\n<span class=\"line\"><span>        - list: List of loss values for each epoch.</span></span>\n<span class=\"line\"><span>        \"\"\"</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> epoch </span><span>in</span><span> range</span><span>(epochs):</span></span>\n<span class=\"line\"><span>            # Perform a training step and compute the average loss for the epoch</span></span>\n<span class=\"line\"><span>            loss </span><span>=</span><span> self</span><span>.train_step(X, y, batch_size)</span></span>\n<span class=\"line\"><span>            self</span><span>.losses.append(loss)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>            #print(loss)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        print</span><span>(</span><span>\"Final Loss:\"</span><span>, loss)</span></span>\n<span class=\"line\"><span>        self</span><span>.plot_training_results()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        #return losses</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> plot_training_results</span><span>(self):</span></span>\n<span class=\"line\"><span>        plt.figure(</span><span>figsize</span><span>=</span><span>(</span><span>25</span><span>, </span><span>8</span><span>))</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # First subplot: Training loss </span></span>\n<span class=\"line\"><span>        plt.subplot(</span><span>1</span><span>, </span><span>3</span><span>, </span><span>1</span><span>)</span></span>\n<span class=\"line\"><span>        plt.plot(np.log(</span><span>self</span><span>.losses), </span><span>label</span><span>=</span><span>\"Log Loss\"</span><span>, </span><span>color</span><span>=</span><span>\"blue\"</span><span>)</span></span>\n<span class=\"line\"><span>        plt.axhline(np.log(</span><span>self</span><span>.losses[</span><span>-</span><span>1</span><span>]), </span><span>linestyle</span><span>=</span><span>\"--\"</span><span>, </span><span>color</span><span>=</span><span>\"red\"</span><span>, </span><span>label</span><span>=</span><span>\"Final Loss\"</span><span>)</span></span>\n<span class=\"line\"><span>        plt.xlabel(</span><span>'Epoch'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.ylabel(</span><span>'Log Loss'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.title(</span><span>'Training Loss Over Time'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.legend()</span></span>\n<span class=\"line\"><span>        plt.grid(</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # Second subplot: weight comparison</span></span>\n<span class=\"line\"><span>        plt.subplot(</span><span>1</span><span>, </span><span>3</span><span>, </span><span>2</span><span>)</span></span>\n<span class=\"line\"><span>        learned_weights </span><span>=</span><span> self</span><span>.w.detach().numpy().flatten()</span></span>\n<span class=\"line\"><span>        true_weights </span><span>=</span><span> true_w.numpy().flatten()</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # Create a scatter plot comparing true vs learned weights</span></span>\n<span class=\"line\"><span>        plt.scatter(true_weights, learned_weights, </span><span>alpha</span><span>=</span><span>0.5</span><span>, </span><span>color</span><span>=</span><span>'blue'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # Add a diagonal line representing perfect prediction</span></span>\n<span class=\"line\"><span>        max_val </span><span>=</span><span> max</span><span>(np.max(true_weights), np.max(learned_weights))</span></span>\n<span class=\"line\"><span>        min_val </span><span>=</span><span> min</span><span>(np.min(true_weights), np.min(learned_weights))</span></span>\n<span class=\"line\"><span>        plt.plot([min_val, max_val], [min_val, max_val], </span><span>'r--'</span><span>, </span><span>label</span><span>=</span><span>'Perfect Match'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        plt.xlabel(</span><span>'True Weights'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.ylabel(</span><span>'Learned Weights'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.title(</span><span>'True vs Learned Weights'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.legend()</span></span>\n<span class=\"line\"><span>        plt.grid(</span><span>True</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        # Third subplot: Bias comparison </span></span>\n<span class=\"line\"><span>        plt.subplot(</span><span>1</span><span>, </span><span>3</span><span>, </span><span>3</span><span>)</span></span>\n<span class=\"line\"><span>        true_bias </span><span>=</span><span> float</span><span>(true_b.numpy().flatten()[</span><span>0</span><span>])</span></span>\n<span class=\"line\"><span>        learned_bias </span><span>=</span><span> float</span><span>(</span><span>self</span><span>.b.detach().numpy().flatten()[</span><span>0</span><span>])</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> [</span><span>'True Bias'</span><span>, </span><span>'Learned Bias'</span><span>]</span></span>\n<span class=\"line\"><span>        y </span><span>=</span><span> [true_bias, learned_bias]</span></span>\n<span class=\"line\"><span>        plt.bar(x, y, </span><span>color</span><span>=</span><span>[</span><span>'blue'</span><span>, </span><span>'orange'</span><span>], </span><span>alpha</span><span>=</span><span>0.7</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        for</span><span> i, val </span><span>in</span><span> enumerate</span><span>(y):</span></span>\n<span class=\"line\"><span>            plt.text(i, val </span><span>+</span><span> 0.02</span><span>, </span><span>f</span><span>'</span><span>{</span><span>val</span><span>:.2f</span><span>}</span><span>'</span><span>, </span><span>ha</span><span>=</span><span>'center'</span><span>, </span><span>fontsize</span><span>=</span><span>10</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        plt.ylabel(</span><span>'Bias Value'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.title(</span><span>'True vs Learned Bias'</span><span>)</span></span>\n<span class=\"line\"><span>        plt.grid(</span><span>axis</span><span>=</span><span>'y'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        plt.tight_layout()</span></span>\n<span class=\"line\"><span>        plt.show()</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>Model </span><span>=</span><span> LinearRegressionModel(num_inputs,learning_rate)</span></span>\n<span class=\"line\"><span>Model.train(X,y,epochs,batch_size)</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>Final Loss:  </span><span>0.07868940383195877</span></span></code></pre>\n<p><img src=\"https://www.tommasovaccari.com/static/output-21-1-fb3fd6c6.webp\" alt=\"Training curves and weight/bias comparison chart\" /></p>\n<h2>Conclusion</h2>\n<p>This example, while simple, highlights some key concepts: we can view a linear regression model as a neural network, and we built this model from scratch without relying on the high-level APIs provided by PyTorch. In this second part of our machine learning series, we’ve taken a step toward higher-level abstraction. In the first post, we explored how each component of a neural network is built under the hood. Here, we leveraged PyTorch’s tensor implementation but still created the model from scratch with everything we needed to train it on our data.</p>","date_published":"2025-01-25T00:00:00.000Z","tags":["NeuralNetworks","Python","Math"]},{"id":"https://www.tommasovaccari.com/blog/introduction-to-neural-networks","url":"https://www.tommasovaccari.com/blog/introduction-to-neural-networks","title":"Neural Networks: Introduction","summary":"Introduction to Neural Networks.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2>Introduction</h2>\n<p>In this post, I would like to introduce how neural networks are built under the hood. I will explore the essential components and mechanisms that enable neural networks to learn and make predictions. By the end of this article, you should have a clearer understanding of how these powerful models work under the hood.</p>\n<h2>Overview of Neural Networks</h2>\n<p>Before diving deeper into neural networks, let’s briefly recap the main goal and how it is achieved. Neural networks (NNs) are a class of machine learning models inspired by the structure and function of the human brain. The primary objective of a neural network is to learn a mapping from input data to desired output values by adjusting its parameters through a process of optimization.</p>\n<h3>1. Goal of neural networks</h3>\n<p>The central aim of a neural network is to learn how to produce accurate outputs when given specific inputs. This process involves training the network on a dataset, where each data point consists of an input and a corresponding target output. For instance, in a classification problem, the input might be an image, and the target output would be the label of the object in the image. Or maybe in a regression problem, we want to train our model to answer the question, how much?</p>\n<h3>2. Training Process</h3>\n<h4>2.1 Feeding data into the net and comparison of the output</h4>\n<p>During training, the network is provided with a set of input data and its corresponding target outputs. The network makes predictions based on the input data, which are then compared to the target outputs. This comparison is crucial for evaluating how well the network is performing.</p>\n<h4>2.2 Loss Function</h4>\n<p>To quantify the difference between the network's predictions and the target outputs, we use a loss function (or cost function). The loss function measures the prediction error. Common loss functions include Mean Squared Error for regression tasks and Cross-Entropy Loss for classification tasks. A lower value of the loss function indicates better performance of the network.</p>\n<h4>2.3 Optimization via backpropagation</h4>\n<p>The goal of training is to minimize the loss function, which involves finding the optimal set of parameters (weights and biases) for the network. To achieve this, we use an optimization algorithm that adjusts the parameters to reduce the loss. Backpropagation is the method used to compute the gradients of the loss function with respect to the network's parameters. It involves:</p>\n<ul>\n<li>Forward Pass: Computing the predictions of the network and the loss.</li>\n<li>Backward Pass: Calculating the gradients of the loss function with respect to each parameter using the chain rule of calculus.</li>\n</ul>\n<h4>2.4 Gradient Descent</h4>\n<p>Once the gradients are computed, they are used by the optimization algorithm (often gradient descent or its variants) to update the network's parameters. The network parameters are adjusted in the direction that reduces the loss function. This process is iterated over multiple epochs (passes through the entire dataset) until the loss function converges to a minimum value or sufficiently small error.</p>\n<p>Roughly speaking, this is the process that we want to follow to achieve our goal. With the foundational concepts established, we will now delve into the detailed steps required to build a neural network. Firstly, we need to create a class that represents data within our neural network. This class must track the origin of each data value, including the operations and values used to compute it, in order to facilitate accurate gradient calculation during backpropagation. We will call this class <em>Value</em>.</p>\n<h2>Breakdown of the code for the Value class</h2>\n<p>Now I will start to implement in code what we have seen in the foundational concepts about NN.</p>\n<h3>Building the basic item of the class and the attribute of the object</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>: </span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, data, _children</span><span>=</span><span>(), _op</span><span>=</span><span>''</span><span>):</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        self</span><span>.data </span><span>=</span><span> data</span></span>\n<span class=\"line\"><span>        self</span><span>._previously </span><span>=</span><span> set</span><span>(_children)</span></span>\n<span class=\"line\"><span>        self</span><span>._operations </span><span>=</span><span> _op</span></span>\n<span class=\"line\"><span>        self</span><span>.grad </span><span>=</span><span> 0.0</span></span>\n<span class=\"line\"><span>        self</span><span>._backward </span><span>=</span><span> lambda</span><span>: </span><span>None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> __repr__</span><span>(self):</span></span>\n<span class=\"line\"><span>        return</span><span> f</span><span>\"Value(data=</span><span>{self</span><span>.data</span><span>}</span><span>)\"</span></span></code></pre>\n<p>Here we initialize the value objects with its own attribute. Let's break down each attribute:</p>\n<ul>\n<li>self.data = data, saves the value of the object</li>\n<li>self._previously = set(_children), saves in a set the children of its value, that means that we save what values generated this value. Thanks to the set we can avoid duplicates</li>\n<li>self._operations = _op, we keep track of what operation generated this value</li>\n<li>self.grad = 0.0, we initially set it to zero, then we are going to modify it accordingly to the rules of backpropagation, we store it in this attribute</li>\n<li>self._backward = lambda: None, we need to store here the function that provides us the gradient, we set it initially to a lambda None because it depends on the operation that generated this Value object.</li>\n</ul>\n<p>The <strong>repr</strong> method is used to print the value object. So for example, if we want to generate a value object we can do as follows:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>a </span><span>=</span><span> Value(</span><span>3</span><span>)</span></span>\n<span class=\"line\"><span>print</span><span>(a)</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> Value(</span><span>data</span><span>=</span><span>3</span><span>)</span></span></code></pre>\n<h3>Building the basic operation and the relative backward function for value object</h3>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>: </span></span>\n<span class=\"line\"><span>    ...</span><span> # existing code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> __add__</span><span>(self, other):</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> Value(</span><span>self</span><span>.data </span><span>+</span><span> other.data, (</span><span>self</span><span>, other), </span><span>'+'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        def</span><span> _backward</span><span>():</span></span>\n<span class=\"line\"><span>            self</span><span>.grad </span><span>+=</span><span> 1.0</span><span> *</span><span> out.grad</span></span>\n<span class=\"line\"><span>            other.grad </span><span>+=</span><span> 1.0</span><span> *</span><span> out.grad</span></span>\n<span class=\"line\"><span>        out._backward </span><span>=</span><span> _backward</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>Here we have the code for the <strong>add</strong> method. Simply in this method, we create an out Value object featured by a new value and a set of children (the two Value objects that originated it) and then we append the operation that originated that new Value object. Then from the fundamentals of calculus, we know that the gradient of the two children due to this operation can be calculated as shown in the code. For much more detail watch gradient explained <a href=\"https://en.wikipedia.org/wiki/Gradient\" rel=\"noopener noreferrer\">here</a>. Let's watch how the add function behaves:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>a </span><span>=</span><span> Value(</span><span>3</span><span>)</span></span>\n<span class=\"line\"><span>b </span><span>=</span><span> Value(</span><span>4</span><span>)</span></span>\n<span class=\"line\"><span>c </span><span>=</span><span> a </span><span>+</span><span> b</span></span>\n<span class=\"line\"><span>print</span><span>(c)</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> Value(</span><span>data</span><span>=</span><span>7</span><span>)</span></span>\n<span class=\"line\"><span>print</span><span>(c._previously)</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> (Value(</span><span>data</span><span>=</span><span>3</span><span>), Value(</span><span>data</span><span>=</span><span>4</span><span>))</span></span>\n<span class=\"line\"><span>print</span><span>(c._operations)</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> '+'</span></span></code></pre>\n<p>Now let's look at the code for the <strong>mul</strong> method:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>:</span></span>\n<span class=\"line\"><span>    ...</span><span> # existing code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> __mul__</span><span>(self, other):</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> Value(</span><span>self</span><span>.data </span><span>*</span><span> other.data, (</span><span>self</span><span>, other), </span><span>'*'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        def</span><span> _backward</span><span>():</span></span>\n<span class=\"line\"><span>            self</span><span>.grad </span><span>+=</span><span> other.data </span><span>*</span><span> out.grad</span></span>\n<span class=\"line\"><span>            other.grad </span><span>+=</span><span> self</span><span>.data </span><span>*</span><span> out.grad</span></span>\n<span class=\"line\"><span>        out._backward </span><span>=</span><span> _backward</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>In the <strong>add</strong> method, we create a new Value object with a new value and a set of children, which are the two Value objects that originated this result. We also append the operation that produced this new Value object. The backward function is designed to compute the gradient according to calculus rules.</p>\n<p>Note that we use the += operator when updating the .grad attribute. This is because we want to accumulate the gradient. For example, if the Value object is involved in multiple operations, the gradient should reflect all these operations, making the gradient cumulative. Generally, this is the process that we follow to create new methods for operations for this class. For the purpose of creating a neural network, we need also a function that can work for us as an <a href=\"https://en.wikipedia.org/wiki/Activation_function\" rel=\"noopener noreferrer\">activation function</a>. In this case, we add the tanh function for this scope.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>:</span></span>\n<span class=\"line\"><span>    ...</span><span> # existing code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> tanh</span><span>(self):</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> self</span><span>.data</span></span>\n<span class=\"line\"><span>        t </span><span>=</span><span> (math.exp(</span><span>2</span><span>*</span><span>x) </span><span>-</span><span> 1</span><span>) </span><span>/</span><span> (math.exp(</span><span>2</span><span>*</span><span>x) </span><span>+</span><span> 1</span><span>)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> Value(t, (</span><span>self</span><span>,), </span><span>'tanh'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        def</span><span> _backward</span><span>():</span></span>\n<span class=\"line\"><span>            self</span><span>.grad </span><span>+=</span><span> (</span><span>1</span><span> -</span><span> t</span><span>**</span><span>2</span><span>) </span><span>*</span><span> out.grad</span></span>\n<span class=\"line\"><span>        out._backward </span><span>=</span><span> _backward</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>From calculus, we know that tanh is defined as:</p>\n<div class=\"formula\"><code>\\tanh(x) = \\frac{e^{2x} - 1}{e^{2x} + 1}</code></div>\n<p>Then we can implement the backward function knowing that:</p>\n<div class=\"formula\"><code>\\frac{d}{dx} \\tanh(x) = 1 - \\tanh^2(x)</code></div>\n<p>Now we implement the exponential method for being able to use tanh:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>:</span></span>\n<span class=\"line\"><span>    ...</span><span> # existing code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> exp</span><span>(self):</span></span>\n<span class=\"line\"><span>        x </span><span>=</span><span> self</span><span>.data</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> Value(math.exp(x), (</span><span>self</span><span>,), </span><span>'exp'</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        def</span><span> _backward</span><span>():</span></span>\n<span class=\"line\"><span>            self</span><span>.grad </span><span>+=</span><span> out.data </span><span>*</span><span> out.grad </span></span>\n<span class=\"line\"><span>        out._backward </span><span>=</span><span> _backward</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        return</span><span> out</span></span></code></pre>\n<p>We have now implemented most of the basic operations for our Value object. The final step is to implement a backward method. This method will be responsible for calling the backward functions of each object in the proper order.</p>\n<h2>Building the backward method</h2>\n<p>Here's the code for the function:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Value</span><span>: </span></span>\n<span class=\"line\"><span>    ...</span><span> # existing code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    def</span><span> backward</span><span>(self):</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        topo </span><span>=</span><span> []</span></span>\n<span class=\"line\"><span>        visited </span><span>=</span><span> set</span><span>()</span></span>\n<span class=\"line\"><span>        def</span><span> build_topo</span><span>(v):</span></span>\n<span class=\"line\"><span>            if</span><span> v </span><span>not</span><span> in</span><span> visited:</span></span>\n<span class=\"line\"><span>                visited.add(v)</span></span>\n<span class=\"line\"><span>                for</span><span> child </span><span>in</span><span> v._previously:</span></span>\n<span class=\"line\"><span>                    build_topo(child)</span></span>\n<span class=\"line\"><span>                topo.append(v)</span></span>\n<span class=\"line\"><span>        build_topo(</span><span>self</span><span>)</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        self</span><span>.grad </span><span>=</span><span> 1.0</span></span>\n<span class=\"line\"><span>        for</span><span> node </span><span>in</span><span> reversed</span><span>(topo):</span></span>\n<span class=\"line\"><span>            node._backward()</span></span></code></pre>\n<p>Thanks to the implementation of the Value object, we are able to construct a computation graph that captures the entire history of how a Value object was derived. This graph is formed by following the _previously set of each Value object, which tracks all the predecessor values involved in its computation.</p>\n<p>To compute the gradients, we begin by focusing on the final Value object, which typically results from a loss function in a neural network. Since we are interested in the gradient of this final Value with respect to itself, we initialize its gradient (self.grad) to 1. This initialization signifies that the gradient of the final Value with respect to itself is 1.</p>\n<p>Following this, we execute the backward pass by invoking the _backward function for each Value object. This process starts with the final Value and proceeds backward through the computation graph to the initial Value objects. This reverse traversal ensures that the gradient for each Value is computed correctly based on the chain rule of differentiation, allowing us to accumulate the gradients appropriately.</p>\n<p>We've now looked at the basics of how a neural network works. This simple approach helps us understand the core concepts behind its operation. Now it's time to create a very simple net.</p>\n<h3>Creating a Neural Network</h3>\n<p>Now we will look more deeply into how to create a net, step by step.</p>\n<h4>Create one artificial neuron</h4>\n<p>Now we can create an <a href=\"https://en.wikipedia.org/wiki/Artificial_neuron\" rel=\"noopener noreferrer\">artificial neuron</a> with two inputs. This is an example of how a neuron works:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span># Inputs x1, x2</span></span>\n<span class=\"line\"><span>x1 </span><span>=</span><span> Value(</span><span>1.0</span><span>)</span></span>\n<span class=\"line\"><span>x2 </span><span>=</span><span> Value(</span><span>2.0</span><span>)</span></span>\n<span class=\"line\"><span># Weights w1, w2</span></span>\n<span class=\"line\"><span>w1 </span><span>=</span><span> Value(</span><span>6.0</span><span>)</span></span>\n<span class=\"line\"><span>w2 </span><span>=</span><span> Value(</span><span>-</span><span>4.0</span><span>)</span></span>\n<span class=\"line\"><span># Bias of the neuron</span></span>\n<span class=\"line\"><span>b </span><span>=</span><span> Value(</span><span>9.8</span><span>)</span></span>\n<span class=\"line\"><span># x1*w1 + x2*w2 + b</span></span>\n<span class=\"line\"><span>x1w1 </span><span>=</span><span> x1 </span><span>*</span><span> w1</span></span>\n<span class=\"line\"><span>x2w2 </span><span>=</span><span> x2 </span><span>*</span><span> w2</span></span>\n<span class=\"line\"><span>x1w1x2w2 </span><span>=</span><span> x1w1 </span><span>+</span><span> x2w2</span></span>\n<span class=\"line\"><span>n </span><span>=</span><span> x1w1x2w2 </span><span>+</span><span> b</span></span>\n<span class=\"line\"><span># Activation function</span></span>\n<span class=\"line\"><span>o </span><span>=</span><span> n.tanh()</span></span></code></pre>\n<p>o is the output of our neuron. The next step is to define the class Neuron, here's the code:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Neuron</span><span>:</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, nin):</span></span>\n<span class=\"line\"><span>        self</span><span>.w </span><span>=</span><span> list</span><span>()</span></span>\n<span class=\"line\"><span>        self</span><span>.b </span><span>=</span><span> Value(random.uniform(</span><span>-</span><span>1</span><span>, </span><span>1</span><span>))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> _ </span><span>in</span><span> range</span><span>(nin):</span></span>\n<span class=\"line\"><span>            self</span><span>.w.append(Value(random.uniform(</span><span>-</span><span>1</span><span>, </span><span>1</span><span>)))</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __call__</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        # w * x + b</span></span>\n<span class=\"line\"><span>        activation </span><span>=</span><span> 0</span></span>\n<span class=\"line\"><span>        for</span><span> wi, xi </span><span>in</span><span> zip</span><span>(</span><span>self</span><span>.w, x):</span></span>\n<span class=\"line\"><span>            activation </span><span>+=</span><span> wi </span><span>*</span><span> xi</span></span>\n<span class=\"line\"><span>        activation </span><span>+=</span><span> self</span><span>.b</span></span>\n<span class=\"line\"><span>        out </span><span>=</span><span> activation.tanh()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        return</span><span> out</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> parameters</span><span>(self): </span><span># For storing the parameters</span></span>\n<span class=\"line\"><span>        return</span><span> self</span><span>.w </span><span>+</span><span> [</span><span>self</span><span>.b]</span></span></code></pre>\n<p>Here we define the class Neuron. In the <strong>init</strong> method, we create the weights based on the number of inputs (nin) and then we create the bias Value. When we call the Neuron, it performs the activation based on the input and produces the result. Here's an example usage:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span>=</span><span> [</span><span>1</span><span>, </span><span>2</span><span>, </span><span>3</span><span>]</span></span>\n<span class=\"line\"><span>a </span><span>=</span><span> Neuron(</span><span>3</span><span>)</span></span>\n<span class=\"line\"><span>print</span><span>(a(x))</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> Value(</span><span>data</span><span>=</span><span>something)</span></span></code></pre>\n<h4>Create a layer</h4>\n<p>The next step is to create a layer made of neurons, here's the code:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> Layer</span><span>:</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, nin, nout):</span></span>\n<span class=\"line\"><span>        self</span><span>.neurons </span><span>=</span><span> list</span><span>()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> _ </span><span>in</span><span> range</span><span>(nout):</span></span>\n<span class=\"line\"><span>            self</span><span>.neurons.append(Neuron(nin))</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __call__</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        outs </span><span>=</span><span> list</span><span>()</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>        for</span><span> neuron </span><span>in</span><span> self</span><span>.neurons:</span></span>\n<span class=\"line\"><span>            outs.append(neuron(x))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        return</span><span> outs[</span><span>0</span><span>] </span><span>if</span><span> len</span><span>(outs) </span><span>==</span><span> 1</span><span> else</span><span> outs</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> parameters</span><span>(self):</span></span>\n<span class=\"line\"><span>        return</span><span> [p </span><span>for</span><span> neuron </span><span>in</span><span> self</span><span>.neurons </span><span>for</span><span> p </span><span>in</span><span> neuron.parameters()]</span></span></code></pre>\n<p>In the <strong>init</strong> method, we initialize a layer specifying the number of inputs for each neuron (nin) and the number of outputs for the layer (nout). When we call it, the <strong>call</strong> method pushes the input to every neuron and returns the output of every neuron.</p>\n<h4>Create the multilayer perceptron</h4>\n<p>The final step is to create an <a href=\"https://en.wikipedia.org/wiki/Multilayer_perceptron\" rel=\"noopener noreferrer\">MLP fully connected</a>, here's the code:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>class</span><span> MLP</span><span>:</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __init__</span><span>(self, nin, nouts):</span></span>\n<span class=\"line\"><span>        sz </span><span>=</span><span> [nin] </span><span>+</span><span> nouts</span></span>\n<span class=\"line\"><span>        self</span><span>.layers </span><span>=</span><span> list</span><span>()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>        for</span><span> i </span><span>in</span><span> range</span><span>(</span><span>len</span><span>(nouts)):</span></span>\n<span class=\"line\"><span>            self</span><span>.layers.append(Layer(sz[i], sz[i</span><span>+</span><span>1</span><span>]))</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> __call__</span><span>(self, x):</span></span>\n<span class=\"line\"><span>        for</span><span> layer </span><span>in</span><span> self</span><span>.layers:</span></span>\n<span class=\"line\"><span>            x </span><span>=</span><span> layer(x)</span></span>\n<span class=\"line\"><span>        return</span><span> x</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    def</span><span> parameters</span><span>(self):</span></span>\n<span class=\"line\"><span>        return</span><span> [p </span><span>for</span><span> layer </span><span>in</span><span> self</span><span>.layers </span><span>for</span><span> p </span><span>in</span><span> layer.parameters()]</span></span></code></pre>\n<p>In the <strong>init</strong> method, as usual, we initialize the MLP by giving the number of inputs of the neurons (nin) and then a list (nouts) where we store the number of neurons for each layer. When we create the self.layer attribute, we iterate over a list that we create where we have the number of inputs and outputs required for every layer. Now let's look at how we can use it:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>x </span><span>=</span><span> [</span><span>1.0</span><span>, </span><span>5.0</span><span>, </span><span>-</span><span>6.0</span><span>]</span></span>\n<span class=\"line\"><span>net </span><span>=</span><span> MLP(</span><span>3</span><span>, [</span><span>4</span><span>, </span><span>4</span><span>, </span><span>1</span><span>])</span></span>\n<span class=\"line\"><span>net(x)</span></span>\n<span class=\"line\"><span>&gt;&gt;&gt;</span><span> Value(</span><span>data</span><span>=</span><span>something)</span></span></code></pre>\n<p>Now we are ready to train the net that we have built.</p>\n<h4>Creating the dataset</h4>\n<p>To start, we can manually create a very easy dataset and the desired target:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>xs </span><span>=</span><span> [</span></span>\n<span class=\"line\"><span>    [</span><span>1.0</span><span>, </span><span>2.0</span><span>, </span><span>-</span><span>1.0</span><span>],</span></span>\n<span class=\"line\"><span>    [</span><span>6.0</span><span>, </span><span>-</span><span>4.0</span><span>, </span><span>1</span><span>],</span></span>\n<span class=\"line\"><span>    [</span><span>2</span><span>, </span><span>1.0</span><span>, </span><span>-</span><span>1.0</span><span>],</span></span>\n<span class=\"line\"><span>    [</span><span>4.0</span><span>, </span><span>3.0</span><span>, </span><span>-</span><span>2.0</span><span>],</span></span>\n<span class=\"line\"><span>]</span></span>\n<span class=\"line\"><span>ys </span><span>=</span><span> [</span><span>2.0</span><span>, </span><span>-</span><span>4.0</span><span>, </span><span>-</span><span>3.0</span><span>, </span><span>2.0</span><span>]</span></span></code></pre>\n<p>So when we feed our net the first list, we want to obtain as a result the first element of the ys list. Now all we need is a loss function that can tell us how good the output of our net is. When we have our loss function, we can call the backpropagation on it (remember that it is a Value object so we can do it) and then we can slightly adjust the parameters according to what minimizes the loss function. Now we can implement it:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\"><code><span class=\"line\"><span>for</span><span> k </span><span>in</span><span> range</span><span>(</span><span>20</span><span>):</span></span>\n<span class=\"line\"><span>  </span></span>\n<span class=\"line\"><span>    # forward pass</span></span>\n<span class=\"line\"><span>    ypred </span><span>=</span><span> list</span><span>()</span></span>\n<span class=\"line\"><span>    for</span><span> x </span><span>in</span><span> xs:</span></span>\n<span class=\"line\"><span>        ypred.append(net(x))</span></span>\n<span class=\"line\"><span>    loss </span><span>=</span><span> 0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    for</span><span> ygt, yout </span><span>in</span><span> zip</span><span>(ys, ypred):</span></span>\n<span class=\"line\"><span>        loss </span><span>+=</span><span> (yout </span><span>-</span><span> ygt)</span><span>**</span><span>2</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # backward pass</span></span>\n<span class=\"line\"><span>    for</span><span> p </span><span>in</span><span> net.parameters():</span></span>\n<span class=\"line\"><span>        p.grad </span><span>=</span><span> 0.0</span></span>\n<span class=\"line\"><span>    loss.backward()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    # update</span></span>\n<span class=\"line\"><span>    for</span><span> p </span><span>in</span><span> net.parameters():</span></span>\n<span class=\"line\"><span>        p.data </span><span>+=</span><span> -</span><span>0.1</span><span> *</span><span> p.grad</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span>    print</span><span>(k, loss.data)</span></span></code></pre>\n<p>In this piece of code, we train our net following these steps:</p>\n<ol>\n<li>Forward pass\nWe feed the net the dataset and then we save the output of the net. Then we evaluate the loss using the <a href=\"https://en.wikipedia.org/wiki/Mean_squared_error\" rel=\"noopener noreferrer\">MSE function</a>.</li>\n<li>Backward Pass\nWe reset all the gradients of all parameters and then we compute the new gradients of each parameter by calling backward on the loss function.</li>\n<li>Update\nWe update the parameters in the direction that minimizes the loss function using a learning rate of 0.1.</li>\n</ol>\n<h2>Conclusion</h2>\n<p>In this first part, we built the fundamentals for understanding neural networks. We built our own API to gain a deeper understanding of how they work.\nIn the next part, we will implement a neural network for linear regression using a higher-level approach.</p>","date_published":"2024-08-03T00:00:00.000Z","tags":["NeuralNetworks","Python","Math"]},{"id":"https://www.tommasovaccari.com/blog/neural-networks-roadmap-and-sources","url":"https://www.tommasovaccari.com/blog/neural-networks-roadmap-and-sources","title":"Neural Networks: Roadmap and Sources","summary":"Roadmap for the Neural Networks series and main sources.","authors":[{"name":"Tommaso Vaccari"}],"content_html":"<h2>Why this series?</h2>\n<p>I have a strong interest in both math and coding, and neural networks sit right at the intersection of the two. They are incredibly powerful architectures that may appear almost magical at first glance — but underneath, what’s really happening is a lot of fascinating mathematics and elegant ideas.</p>\n<p>Since I usually take notes while studying, I decided to turn them into written explanations. This way I can both strengthen my own understanding and retention, and hopefully spark curiosity in others who might be interested in this field.</p>\n<p>In this series, as I learn new concepts, I’ll write new blog posts that mix math and theoretical deep dives with practical coding examples. These posts are not meant to be a primary reference, but rather an accessible and engaging exploration. For this reason I am going to provide, down below, all the sources I have studied and consumed to deepen my knowledge on the topic.</p>\n<h2>Structure of the Series</h2>\n<p>This series will grow step by step as I deepen my understanding of neural networks. Each post focuses on a specific building block. For now, the planned chapters are:</p>\n<ol>\n<li><strong><a href=\"https://www.tommasovaccari.com/blog/introduction-to-neural-networks\" rel=\"noopener noreferrer\">Introduction to Neural Networks</a></strong></li>\n<li><strong><a href=\"https://www.tommasovaccari.com/blog/linear-regression\" rel=\"noopener noreferrer\">Linear Regression with Neural Networks</a></strong></li>\n<li><strong><a href=\"https://www.tommasovaccari.com/blog/from-bigrams-to-neural-networks\" rel=\"noopener noreferrer\">From Bigrams to Neural Networks: The First Step in Language Modeling</a></strong></li>\n<li><strong>Key Ideas Behind the Transformer Architecture</strong></li>\n<li><strong>Training a Small GPT on a Curated Dataset</strong></li>\n</ol>\n<h2>Sources</h2>\n<p>Since this series is meant as a personal exploration rather than a formal reference, I’ll list here the materials I study along the way. These sources are diverse — books, academic papers, online courses, blog posts, and videos — so that anyone interested can follow the same path and dig deeper.</p>\n<h3>Books</h3>\n<ul>\n<li><em>Deep Learning</em> — Ian Goodfellow, Yoshua Bengio, Aaron Courville</li>\n<li><em>Neural Networks and Deep Learning</em> — Michael Nielsen</li>\n<li><a href=\"https://d2l.ai/\" rel=\"noopener noreferrer\">Dive into Deep Learning</a></li>\n<li>Concise Machine Learning — Jonathan Richard Shewchuk</li>\n</ul>\n<h3>Academic Papers</h3>\n<ul>\n<li><a href=\"https://jmlr.org/papers/volume3/bengio03a/bengio03a.pdf\" rel=\"noopener noreferrer\">A Neural Probabilistic Language Model</a> — Bengio et al., 2003</li>\n<li><a href=\"https://arxiv.org/abs/1502.03167\" rel=\"noopener noreferrer\">Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift</a> — Ioffe &amp; Szegedy, 2015</li>\n<li><a href=\"https://arxiv.org/abs/2105.07576\" rel=\"noopener noreferrer\">Rethinking “Batch” in BatchNorm</a> — Bjorck et al., 2021</li>\n<li><a href=\"https://courses.cs.duke.edu/spring20/compsci527/papers/Domingos.pdf\" rel=\"noopener noreferrer\">A Few Useful Things to Know about Machine Learning</a> — Pedro Domingos, 2012</li>\n<li><a href=\"https://arxiv.org/abs/1502.01852\" rel=\"noopener noreferrer\">Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification</a> — He et al., 2015</li>\n<li><a href=\"https://arxiv.org/abs/1609.03499\" rel=\"noopener noreferrer\">WaveNet: A Generative Model for Raw Audio</a> — van den Oord et al., 2016</li>\n<li><a href=\"https://jmlr.org/papers/v15/srivastava14a.html\" rel=\"noopener noreferrer\">Dropout: A Simple Way to Prevent Neural Networks from Overfitting</a> — Srivastava et al., 2014</li>\n<li><a href=\"https://arxiv.org/abs/1512.03385\" rel=\"noopener noreferrer\">Deep Residual Learning for Image Recognition</a> — He et al., 2015</li>\n<li><a href=\"https://arxiv.org/abs/1706.03762\" rel=\"noopener noreferrer\">Attention Is All You Need</a> — Vaswani et al., 2017</li>\n</ul>\n<h3>Blogs &amp; Articles</h3>\n<ul>\n<li>All the Andrej Karpathy Blogs: <a href=\"https://karpathy.ai/zero-to-hero.html\" rel=\"noopener noreferrer\">Zero to Hero</a>, <a href=\"https://karpathy.github.io/\" rel=\"noopener noreferrer\">Blog</a>, <a href=\"https://karpathy.ai/\" rel=\"noopener noreferrer\">Website</a></li>\n</ul>\n<h3>Videos</h3>\n<ul>\n<li><a href=\"https://youtu.be/aircAruvnKk?feature=shared\" rel=\"noopener noreferrer\">3Blue1Brown — Neural Networks series</a></li>\n<li><a href=\"https://www.youtube.com/@YannicKilcher\" rel=\"noopener noreferrer\">Yannic Kilcher — Paper walkthroughs (Transformers, GPTs, etc.)</a></li>\n<li><a href=\"https://www.youtube.com/playlist?list=PLAqhIrjkxbuWI23v9cThsA9GvCAUhRvKZ\" rel=\"noopener noreferrer\">Andrej Karpathy — Neural Networks: Zero to Hero series</a></li>\n</ul>\n<h2>Conclusion</h2>\n<p>I hope you'll find something useful in this series and I encourage you to keep exploring this field!</p>","date_published":"2024-08-01T00:00:00.000Z","tags":["NeuralNetworks","Math","Sources"]}]}