<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pragalva Sapkota</title>
    <description>The latest articles on DEV Community by Pragalva Sapkota (@pragalva).</description>
    <link>https://dev.to/pragalva</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4060988%2Fa28d6d78-fb3e-4b90-bdac-df09d2c00d7d.jpeg</url>
      <title>DEV Community: Pragalva Sapkota</title>
      <link>https://dev.to/pragalva</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pragalva"/>
    <language>en</language>
    <item>
      <title>Observing the Probabilities</title>
      <dc:creator>Pragalva Sapkota</dc:creator>
      <pubDate>Thu, 24 Sep 2026 17:09:44 +0000</pubDate>
      <link>https://dev.to/pragalva/observing-the-probabilities-1d5l</link>
      <guid>https://dev.to/pragalva/observing-the-probabilities-1d5l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pragalva.me/blog/observing-the-probabilities/" rel="noopener noreferrer"&gt;pragalva.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Abstract
&lt;/h2&gt;

&lt;p&gt;The observations here come from a small experiment following &lt;em&gt;A Neural Probabilistic Language Model&lt;/em&gt; (Bengio et al., 2003). In the last blog, we looked at how predicting the next word could help a model learn relationships between words. Here, I wanted to see that happen in the embedding matrix itself.&lt;/p&gt;

&lt;p&gt;I highly suggest reading &lt;a href="https://www.jmlr.org/papers/volume3/bengio03a/bengio03a.pdf" rel="noopener noreferrer"&gt;the paper&lt;/a&gt; or my last blog, &lt;a href="https://pragalva.me/blog/viewing-language-through-a-probabilistic-lens/" rel="noopener noreferrer"&gt;Viewing Language Through a Probabilistic Lens&lt;/a&gt;, which covers the theory behind this experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the C learn
&lt;/h2&gt;

&lt;p&gt;Our entire corpus is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the cat sat on the mat the dog sat on the rug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve words, with 7 unique words in the vocabulary. The model uses two words of context to predict the next one, giving us 10 training examples.&lt;/p&gt;

&lt;p&gt;C is the embedding matrix. It has the shape [V, m], where V is the vocabulary size and m is the embedding dimension. Here, that is [7, 5]: one row of five numbers for each word. We start by filling it with random numbers.&lt;/p&gt;

&lt;p&gt;With those initial values, here is what the model predicts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Expected word&lt;/th&gt;
&lt;th&gt;Model predicts&lt;/th&gt;
&lt;th&gt;Probability of the expected word&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the cat&lt;/td&gt;
&lt;td&gt;sat&lt;/td&gt;
&lt;td&gt;cat&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the dog&lt;/td&gt;
&lt;td&gt;sat&lt;/td&gt;
&lt;td&gt;the&lt;/td&gt;
&lt;td&gt;0.011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on the&lt;/td&gt;
&lt;td&gt;mat&lt;/td&gt;
&lt;td&gt;the&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Probabilities are rounded to three decimal places. That last value is very small, rather than exactly zero.&lt;/p&gt;

&lt;p&gt;The model has no useful representation of these words yet. To learn, it compares its predictions with the actual next words using cross-entropy loss. Backpropagation gives us the gradients, and we use them to adjust the parameters in the direction that reduces the loss. This includes the rows of C used for that prediction.&lt;/p&gt;

&lt;p&gt;Meaning, when the model gets "the cat" wrong, the numbers representing "the" and "cat" also get updated. We never tell it what a cat is. Its representation changes because it helps predict what comes next.&lt;/p&gt;

&lt;p&gt;Let's follow the same examples through training:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;sat after "the cat"&lt;/th&gt;
&lt;th&gt;sat after "the dog"&lt;/th&gt;
&lt;th&gt;mat after "on the"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;td&gt;0.011&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.637&lt;/td&gt;
&lt;td&gt;0.286&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;0.875&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.052&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0.931&lt;/td&gt;
&lt;td&gt;0.940&lt;/td&gt;
&lt;td&gt;0.393&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;0.997&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;td&gt;0.499&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-tracked.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-tracked.png" alt="Target-word probabilities during training. sat after the cat and the dog rises toward 1, while mat after on the levels off near 0.5." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model learns to predict sat after both "the cat" and "the dog". But mat after "on the" only reaches about 0.5, even after 1000 steps. We will come back to why.&lt;/p&gt;

&lt;p&gt;For this run, the embedding dimension is 5 and the hidden layer has 8 units, with the model's direct connections enabled. I used seed 42, standard normal initialisation, and 1000 full-batch gradient descent steps at a learning rate of 0.1. The implementation is in &lt;a href="https://github.com/PragalvaXFREZ/embedding-models-from-scratch/tree/main/01-bengio-2003/observing-the-probabilities" rel="noopener noreferrer"&gt;embedding-models-from-scratch&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chasing the cross-entropy to null
&lt;/h2&gt;

&lt;p&gt;As the parameters change, the loss falls from 6.41 to 0.18 in the first 100 steps. But the remaining 900 steps only take it to 0.1415. The updates to C also become much smaller.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-loss.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-loss.png" alt="The loss falls quickly and then levels off near 0.14. Alongside it, the gradient norm of C falls from about 1.4 to 0.003 over 1000 steps." width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We have 256 free parameters for just 10 examples. So why does the loss stop falling?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a perfect model need a loss of 0?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at these two examples from our corpus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on the → mat
on the → rug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model receives exactly the same input for both. It cannot give mat a probability of 1 in one example and rug a probability of 1 in the other. Each occurs once, so the best it can do across these examples is split the probability equally.&lt;/p&gt;

&lt;p&gt;Cross-entropy for one example is −ln P(target). A probability of 1 gives a loss of 0, but a probability of 0.5 gives about 0.693. Even if the model gets the other eight examples perfectly right, the lowest average loss is:&lt;/p&gt;

&lt;p&gt;(8 × 0 + 2 × (−ln 0.5)) / 10 ≈ 0.1386&lt;/p&gt;

&lt;p&gt;Our model reaches 0.1415, already very close. And the 0.499 we saw for mat makes sense now. The model is leaving almost the other half for rug.&lt;/p&gt;

&lt;p&gt;In natural language, this uncertainty comes up all the time. After "I drank a cup of", the next word could be tea, coffee, milk, or water. Given only that context, there may be several valid answers. The loss left even when a model matches their probabilities is called &lt;strong&gt;irreducible loss&lt;/strong&gt;, or the entropy of the next word given the context.&lt;/p&gt;

&lt;p&gt;For our tiny corpus, the context matters quite literally. With four words instead of two, the model could distinguish "cat sat on the" from "dog sat on the". The ambiguity disappears from these examples. So 0.1386 is the floor for this corpus with our two-word window, not a fixed limit of language.&lt;/p&gt;

&lt;p&gt;We do not need a loss of 0 to say the model has learned these probabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  The relationship we wanted but couldn't have
&lt;/h2&gt;

&lt;p&gt;Normal n-gram models can predict the next word too. What interested me here was whether learning to predict would also bring related words closer together in C.&lt;/p&gt;

&lt;p&gt;Cat and dog play similar roles in our corpus. So do mat and rug. We can see that when we read it, but does it show up in their representations?&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;cosine similarity&lt;/strong&gt; to compare the rows of C. It measures how closely two vectors point in the same direction: 1 means the same direction, 0 means perpendicular, and -1 means opposite directions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-pairs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fobserving-the-probabilities-pairs.png" alt="Cosine similarity during training. Cat and dog move from -0.13 to 0.13. Mat and rug move from 0.35 down to 0.23." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cat and dog move a little closer, from -0.13 to 0.13. Mat and rug actually become less similar, going from 0.35 to 0.23. After training, cat's closest word is sat, with a similarity of 0.81.&lt;/p&gt;

&lt;p&gt;The predictions improved, but the relationships I expected were barely there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The embedding that never learns
&lt;/h3&gt;

&lt;p&gt;Rug's row in C does not change at all. After 1000 steps, its gradient is still:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0.0, 0.0, 0.0, 0.0, 0.0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go back to the corpus. Rug is the last word. It appears as a target, but never as part of the context used to predict another word.&lt;/p&gt;

&lt;p&gt;The lookup &lt;code&gt;C[X]&lt;/code&gt; only selects rows for words in the context. Since rug is never selected, its row never receives a gradient. There is nothing updating its embedding.&lt;/p&gt;

&lt;p&gt;The model can still learn to &lt;em&gt;predict&lt;/em&gt; rug because the output weights are trained separately. But the row representing rug as an &lt;em&gt;input&lt;/em&gt; stays exactly as it started. The mat/rug similarity changed only because mat moved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dog and cat
&lt;/h3&gt;

&lt;p&gt;Cat and dog both appear in the context, and both help predict sat and on. Their embeddings do move in similar directions during training. So why do they end up only slightly closer?&lt;/p&gt;

&lt;p&gt;They start with different random values, and the model learns these few examples before the embeddings have changed very much. As the predictions approach the best probabilities for this corpus, the overall gradient gets smaller. There is less left to update.&lt;/p&gt;

&lt;p&gt;With 256 parameters, the model has enough room to fit these examples while keeping cat and dog quite different. Making their embeddings similar is not a separate goal in the loss function.&lt;/p&gt;

&lt;p&gt;I also tried 10 different random seeds. The average final similarity was 0.31 for cat/dog and -0.09 for mat/rug. So changing the starting numbers did not resolve it either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if we add more text?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I first copied the same 10 training examples 50 times. We now have 500 examples, but the vocabulary is still 7 words, and there are still only 9 distinct context/target pairs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Training data&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Distinct context/target pairs&lt;/th&gt;
&lt;th&gt;cat/dog similarity&lt;/th&gt;
&lt;th&gt;mat/rug similarity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0.31&lt;/td&gt;
&lt;td&gt;-0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Examples copied 50 times&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0.31&lt;/td&gt;
&lt;td&gt;-0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are averages over 10 seeds, with 1000 training steps per run.&lt;/p&gt;

&lt;p&gt;No improvement. We calculate the loss as an average, so repeating every example equally leaves that average, and its gradient, unchanged. The model keeps receiving the same update at each step.&lt;/p&gt;

&lt;p&gt;Joining the text into one long repeated string adds two contexts at the joins: "rug the" → cat and "the rug" → the. Rug finally gets used as an input, but the similarities still barely change: 0.34 for cat/dog and -0.07 for mat/rug.&lt;/p&gt;

&lt;p&gt;We gave it more text, but almost nothing new to learn from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the corpus had more combinations?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next, I used every combination of 4 animals, 3 verbs, and 4 things in this sentence pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the {cat, dog, fox, cow} {sat, lay, slept} on the {mat, rug, sofa, bed} .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives us 48 sentences and a vocabulary of 14 tokens, including the full stop. The full stop also means rug can now appear in a context before the sentence ends.&lt;/p&gt;

&lt;p&gt;I kept out the 8 sentences containing "fox slept" or "cow lay", and trained on the other 40. The model sees all the words during training, but not those combinations. The &lt;strong&gt;held-out loss&lt;/strong&gt; measures how well it predicts on the sentences we kept aside. Lower is better.&lt;/p&gt;

&lt;p&gt;For this larger corpus, I trained for 3000 steps per run. These results, and the remaining comparisons, are averages over 10 seeds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;cat/dog similarity&lt;/th&gt;
&lt;th&gt;mat/rug similarity&lt;/th&gt;
&lt;th&gt;Training loss&lt;/th&gt;
&lt;th&gt;Held-out loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;td&gt;0.529&lt;/td&gt;
&lt;td&gt;1.240&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the relationships are more visible. Cat and dog become much closer, and mat and rug improve too.&lt;/p&gt;

&lt;p&gt;But the model still does much better on the sentences it trained on. The training loss is almost at this corpus's floor of 0.526, while the held-out loss is 1.240. More combinations helped, but there is still room to improve on the unseen sentences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the model had less capacity?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I reduced the embedding dimension from 5 to 2 and the hidden layer from 8 units to 4. On the larger vocabulary, that takes the model from 424 parameters to 174. The corpus and training steps stay the same.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;cat/dog similarity&lt;/th&gt;
&lt;th&gt;mat/rug similarity&lt;/th&gt;
&lt;th&gt;Training loss&lt;/th&gt;
&lt;th&gt;Held-out loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original size&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;td&gt;0.529&lt;/td&gt;
&lt;td&gt;1.240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smaller&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;0.536&lt;/td&gt;
&lt;td&gt;0.872&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both pairs become much closer, and the held-out loss falls. The smaller model fits the training examples slightly less well, but does better on the sentences it has never seen.&lt;/p&gt;

&lt;p&gt;Reducing the size does not guarantee similar embeddings. In this experiment, though, giving the model less room to fit each combination separately helped it learn representations that worked across the combinations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if we add weight decay?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The paper uses weight decay, which I had left out of this setup. I added it to the original-sized model on the larger corpus.&lt;/p&gt;

&lt;p&gt;Weight decay adds a penalty for large parameter values. Here, that penalty is λ times the sum of squared values in C, H, U, and W. The biases are left out, and λ controls how strong the penalty is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weight decay&lt;/th&gt;
&lt;th&gt;cat/dog similarity&lt;/th&gt;
&lt;th&gt;mat/rug similarity&lt;/th&gt;
&lt;th&gt;Training loss&lt;/th&gt;
&lt;th&gt;Held-out loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;td&gt;0.529&lt;/td&gt;
&lt;td&gt;1.240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;0.542&lt;/td&gt;
&lt;td&gt;0.937&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.670&lt;/td&gt;
&lt;td&gt;0.724&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The losses shown here are cross-entropy alone, without the added penalty, so we can compare them with the previous runs.&lt;/p&gt;

&lt;p&gt;At 0.01, the paired embeddings point in almost the same direction. This setting also gives the lowest held-out loss of the ones I tried, even though its training loss is higher.&lt;/p&gt;

&lt;p&gt;That makes sense for the corpus we built. Every animal and every thing can occupy its group's slot in the sentence. Real language has more differences to preserve. Cat and dog having almost identical directions here does not mean that should be our goal for every corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In our &lt;a href="https://pragalva.me/blog/viewing-language-through-a-probabilistic-lens/" rel="noopener noreferrer"&gt;previous blog&lt;/a&gt;, we covered the architecture and tried understanding the underlying mechanism. In this blog, I tried running it. This is not the current state-of-the-art model, and it is way behind the frontier. The purpose of the blog was to understand how complex relationships are learned and represented in vectors.&lt;/p&gt;

&lt;p&gt;While the world is busy chasing the frontier, I think understanding its foundations is just as important. The purpose of this blog and the entire series is to build intuition. In a way, I am trying to build my own embedding matrix C such that I start building the correct research intuition.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>llm</category>
      <category>ai</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>Understanding LLMs</title>
      <dc:creator>Pragalva Sapkota</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:59:52 +0000</pubDate>
      <link>https://dev.to/pragalva/understanding-llms-54ln</link>
      <guid>https://dev.to/pragalva/understanding-llms-54ln</guid>
      <description>&lt;h3&gt;
  
  
  Motivation
&lt;/h3&gt;

&lt;p&gt;This is the beginning of a blog series where I try to understand large language models by reading &lt;em&gt;Build a Large Language Model (From Scratch)&lt;/em&gt; by Sebastian Raschka and writing down what I learn along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Table of Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Introduction to LLMs&lt;/li&gt;
&lt;li&gt;Architectures behind LLMs

&lt;ul&gt;
&lt;li&gt;Recurrent and convolutional architecture&lt;/li&gt;
&lt;li&gt;Transformer architecture&lt;/li&gt;
&lt;li&gt;Self-attention mechanism&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;BERT vs GPT&lt;/li&gt;
&lt;li&gt;Understanding GPT architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's an LLM?
&lt;/h3&gt;

&lt;p&gt;A large language model is a neural network that is designed to understand, generate, and respond to text. The fundamental understanding here is that large language models are trained on huge datasets, from web-scraped data to Wikipedia.&lt;/p&gt;

&lt;p&gt;They utilize the transformer architecture, but note that there are some LLMs that are based upon alternative architectures like recurrent and convolutional architectures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recurrent Architecture
&lt;/h3&gt;

&lt;p&gt;A recurrent neural network reads the text sequentially.&lt;/p&gt;

&lt;p&gt;The model processes a sentence like "I grew up in Nepal, so I speak ..." in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I      -&amp;gt; update memory
grew   -&amp;gt; update memory
up     -&amp;gt; update memory
in     -&amp;gt; update memory
Nepal  -&amp;gt; update memory
...
speak  -&amp;gt; use memory to predict next token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Languages are mostly sequential. Therefore, prediction is possible using RNNs, but the main problem here lies in efficiency. After reading each token, or word, the model requires a compressed memory of everything so far. Using this memory, the model predicts the next token.&lt;/p&gt;

&lt;p&gt;This works for small sentences, but for larger sentences this architecture can lead to context loss if the memory was compressed badly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Convolutional Architecture
&lt;/h3&gt;

&lt;p&gt;A convolutional language model does not process the sequence recurrently one token at a time like an RNN. Instead, it applies filters over local token windows. But if it is used as a language model, it can still be trained to predict the next token.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The food I ate at the restaurant last week was really good compared to the restaurant I ate at yesterday.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we take filter size = 4 and stride = 2.&lt;/p&gt;

&lt;p&gt;Filter size, or kernel size, determines how many neighboring tokens the model looks at once. Stride determines how many tokens the window shifts each time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The food I ate
I ate at the
at the restaurant last
restaurant last week was
week was really good
...
I ate at yesterday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It learns local patterns. Words alone can have a different, or even reverse, meaning when nearby words are accounted for. But this still does not directly solve it entirely, as the association between the food being great can be mistaken for "week was really good" when accounted for locally. A convolutional model needs stacked layers to gradually expand its view.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transformer Architecture
&lt;/h3&gt;

&lt;p&gt;A transformer is a deep neural network architecture that consists of two submodules: an encoder and a decoder. This architecture was originally designed for machine translation.&lt;/p&gt;

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

&lt;p&gt;In simple words, the encoder processes the input text and encodes it into a series of numerical representations: vectors that capture the contextual information of the input. Then, the decoder module takes these encoded vectors and generates the output text.&lt;/p&gt;

&lt;p&gt;Let's take an example. "My name is Pragalva" is an input text that will be preprocessed before it enters the encoder. Preprocessing means converting raw input text into numerical input embeddings that a transformer can process. These embeddings are then converted into vectors, and the decoder decodes those vectors to generate text in the target language.&lt;/p&gt;

&lt;p&gt;These explanations are surface level and will be explored in depth in the coming blogs as I continue my reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Attention Mechanism
&lt;/h3&gt;

&lt;p&gt;Self-attention is a key component of transformer architecture, and it sets transformers apart from the architectures we explored previously. This mechanism allows the model to weigh the importance of different tokens in a sequence relative to each other.&lt;/p&gt;

&lt;p&gt;This also helps solve the problem we discussed with CNNs. With a self-attention mechanism, the model is able to capture long-range dependencies and contextual relationships within the input data, resulting in more contextually relevant output.&lt;/p&gt;

&lt;h3&gt;
  
  
  BERT vs GPT
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfub0ezc4sfpgzohez0j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfub0ezc4sfpgzohez0j.png" alt="BERT vs GPT architecture comparison" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;BERT and GPT are two variants that originated from the transformer architecture. BERT, short for Bidirectional Encoder Representations from Transformers, focuses on the encoder side of the transformer architecture. GPT, short for Generative Pre-trained Transformer, was built on the decoder submodule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding GPT Architecture
&lt;/h3&gt;

&lt;p&gt;Compared to the original transformer architecture we covered earlier, the general GPT architecture is relatively simple. Essentially, it is just the decoder part.&lt;/p&gt;

&lt;p&gt;Since decoder-style models like GPT generate text by predicting text one word at a time, they are considered a type of autoregressive model. Autoregressive models incorporate their previous outputs as inputs for future predictions. Consequently, in GPT, each new word is chosen based on the sequence that precedes it, which improves the coherence of the resulting text. GPT architecture is the foundation for the majority of the models that we currently use in our daily workflows.&lt;/p&gt;

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

&lt;p&gt;As seen above, the GPT architecture only uses the decoder portion of the transformer. GPT is also designed for unidirectional processing, left to right in this case, and is well suited for next-token prediction and text generation.&lt;/p&gt;

&lt;p&gt;While simpler decoder-only models like GPT are aimed at next-token prediction, they are also capable of performing tasks that they were not initially trained for. This phenomenon is called &lt;em&gt;emergent behavior&lt;/em&gt;, meaning the capability was not explicitly taught during training but emerges naturally. For example, GPT models trained for next-token prediction can also perform translations as a natural consequence of the model's exposure to vast quantities of multilingual data in diverse contexts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;p&gt;In this blog, we mainly discussed LLMs and the architecture behind them. While we might have just scratched the surface, these foundational topics are required as we dive further into these concepts.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://pragalva.me/blog/understanding-llms/" rel="noopener noreferrer"&gt;pragalva.me&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Made My Homelab Declarative</title>
      <dc:creator>Pragalva Sapkota</dc:creator>
      <pubDate>Mon, 10 Aug 2026 16:24:53 +0000</pubDate>
      <link>https://dev.to/pragalva/how-i-made-my-homelab-declarative-1lmc</link>
      <guid>https://dev.to/pragalva/how-i-made-my-homelab-declarative-1lmc</guid>
      <description>&lt;h3&gt;
  
  
  Motivation
&lt;/h3&gt;

&lt;p&gt;Prior to this, all of the applications that were installed in devata (my homelab cluster) were installed through &lt;code&gt;helm install&lt;/code&gt;. I wanted to explore GitOps and wanted to adopt a declarative approach rather than an imperative approach.&lt;/p&gt;

&lt;p&gt;The imperative setup worked, but it had a shape I did not like. The only record of &lt;em&gt;what&lt;/em&gt; was running and &lt;em&gt;how&lt;/em&gt; it was configured lived inside the cluster itself, in Helm release secrets, and in my shell history. If I wanted to upgrade a chart, I had to remember which values I had passed months ago. If the cluster died, the recovery plan was "reinstall everything from memory". And every change was a one-off command that left no trace anywhere I could review, diff, or roll back.&lt;/p&gt;

&lt;p&gt;The instinct came from somewhere. During my LFX term I worked on &lt;a href="https://github.com/meshery/schemas" rel="noopener noreferrer"&gt;meshery/schemas&lt;/a&gt;, the source of truth for the whole Meshery project: every construct is defined once there, and every component downstream takes reference from it. I wanted my cluster to work the same way.&lt;/p&gt;

&lt;p&gt;That arrangement, applied to a cluster, is GitOps. The desired state of the cluster lives in a git repository, and a reconciler running inside the cluster continuously pulls that state and applies it. Git becomes the source of truth: a change is a commit, a review is a pull request, a rollback is a revert. If something drifts from what git says, the reconciler puts it back. If a change is not in a file, it does not really exist.&lt;/p&gt;

&lt;p&gt;Everything below lives in my public &lt;a href="https://github.com/PragalvaXFREZ/lab" rel="noopener noreferrer"&gt;lab&lt;/a&gt; repository, so you can read the real manifests as we go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Table of Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Argo CD&lt;/li&gt;
&lt;li&gt;The repository layout&lt;/li&gt;
&lt;li&gt;The app-of-apps&lt;/li&gt;
&lt;li&gt;Migrating the imperative stack&lt;/li&gt;
&lt;li&gt;The adoption drill&lt;/li&gt;
&lt;li&gt;What each component taught me&lt;/li&gt;
&lt;li&gt;The proof&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Argo CD
&lt;/h3&gt;

&lt;p&gt;I chose Argo CD for this. It is the reconciler: it watches a git repository, compares what is declared there against what is actually running in the cluster, and syncs the difference.&lt;/p&gt;

&lt;p&gt;For the install itself I first went with the raw upstream &lt;code&gt;install.yaml&lt;/code&gt;, committed straight into the repo, instead of the Helm chart or Autopilot. The reasoning was transparency: you can read every resource the installer creates, and committing it means even the installer is versioned. That choice did not survive contact with automation. Once Renovate started watching the repo, a 33,000 line vendored manifest was invisible to it; a pinned reference is not. So the install is now a small kustomization pointing at the exact upstream base that &lt;code&gt;install.yaml&lt;/code&gt; is generated from, pinned by tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/bootstrap/argocd/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github.com/argoproj/argo-cd/manifests/cluster-install?ref=v3.4.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property I actually cared about survives: the installer is still versioned, because the pin &lt;em&gt;is&lt;/em&gt; the version. What changed is the shape of an upgrade. It used to be a regenerated 33,000 line file that no one truly reviews; now it is a one line diff that Renovate opens as a PR and CI renders before merge. Bootstrapping a fresh cluster is still two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace argocd
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; kubernetes/bootstrap/argocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The neat part is what happens next: Argo CD manages &lt;em&gt;itself&lt;/em&gt;. One of the Applications it reconciles points right back at the directory that defines its own installation. Upgrading Argo CD is now editing a file and pushing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/clusters/devata/argocd.yaml&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/PragalvaXFREZ/lab.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes/bootstrap/argocd&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# never let argocd prune its own install&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ServerSideApply=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that snippet carry weight. &lt;code&gt;prune: false&lt;/code&gt; means Argo CD will never delete pieces of its own installation, even if they disappear from git; a bad commit should not be able to take down the engine that would fix it. And &lt;code&gt;ServerSideApply=true&lt;/code&gt; is there because Argo CD's CRDs are so large they blow past the size limit of the client-side &lt;code&gt;last-applied-configuration&lt;/code&gt; annotation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The repository layout
&lt;/h3&gt;

&lt;p&gt;The repo is split into three planes, by &lt;em&gt;who applies them&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lab/
├── talos/              # machine configs, applied by talosctl
├── kubernetes/         # the ONLY path GitOps watches
│   ├── bootstrap/      # argocd install + the root app
│   ├── clusters/
│   │   └── devata/     # one child Application per component
│   ├── infra/          # values and manifests per component
│   │   ├── networking/     # cilium, metallb
│   │   ├── observability/  # kube-prometheus-stack, loki, promtail
│   │   └── controllers/    # nvidia-device-plugin, sealed-secrets
│   └── apps/           # workloads
└── lab-experiments/    # sandbox, never reconciled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubernetes/&lt;/code&gt; is the single GitOps-watched path. &lt;code&gt;talos/&lt;/code&gt; is applied by &lt;code&gt;talosctl&lt;/code&gt; from my workstation, because the machine layer has to exist before there is a cluster to reconcile anything. And &lt;code&gt;lab-experiments/&lt;/code&gt; is the escape hatch: a place to try things without the reconciler ever touching them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The app-of-apps
&lt;/h3&gt;

&lt;p&gt;Argo CD's unit of work is an &lt;code&gt;Application&lt;/code&gt;: one CRD instance that says "this git path renders to these resources, keep them in this namespace". Instead of registering every component by hand in the UI, I use the app-of-apps pattern: a single root Application that watches a directory of &lt;em&gt;other&lt;/em&gt; Application manifests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/bootstrap/root.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devata-root&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/PragalvaXFREZ/lab.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes/clusters/devata&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;recurse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;devata-root&lt;/code&gt; watches &lt;code&gt;kubernetes/clusters/devata/&lt;/code&gt;. Every YAML file in that directory is itself an Application pointing at a component. Adding a component to the cluster is now: commit a child Application file, push, done. The root notices the new file, creates the Application, and that Application syncs the component. Deleting the file prunes it. The whole cluster inventory is one &lt;code&gt;ls&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;argocd.yaml  cilium.yaml  hello.yaml  kps.yaml  loki.yaml
metallb.yaml  nvidia-device-plugin.yaml  promtail.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This root app is the only thing I ever applied manually with &lt;code&gt;kubectl apply -f root.yaml&lt;/code&gt;. Everything after that arrived through git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating the imperative stack
&lt;/h3&gt;

&lt;p&gt;Bootstrapping the engine on a fresh cluster is the easy half. My problem was the brownfield half: devata already had six components running as hand-installed Helm releases, some of them load-bearing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ helm list -A
NAME                    NAMESPACE
cilium                  kube-system
kps                     monitoring
loki                    logging
metallb                 metallb-system
nvidia-device-plugin    nvidia-device-plugin
promtail                logging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive move would be a big-bang takeover: point Argo CD at everything at once and let it sync. On a cluster where one of those releases is the CNI (the thing pods need to have network at all) and another is the entire monitoring stack, that is how you turn a learning exercise into an outage. So the migration went one component at a time, least blast radius first, and each one followed the same drill.&lt;/p&gt;

&lt;p&gt;The key realization that makes adoption safe: Argo CD does not care &lt;em&gt;how&lt;/em&gt; resources got into the cluster. If the manifests it renders from git match what is already running, the diff is empty and syncing changes nothing. So the whole game is to reproduce, in git, exactly what Helm had installed, and to prove the diff is empty before letting the reconciler touch anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The adoption drill
&lt;/h3&gt;

&lt;p&gt;Per component, the workflow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set up the GitHub repository, the app-of-apps root Application, and configure your repository to serve as the source of truth (once, see above).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;helm list -A&lt;/code&gt; to find the next release to migrate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;helm get values &amp;lt;release&amp;gt; -n &amp;lt;namespace&amp;gt;&lt;/code&gt; to recover the exact values the release was installed with. This is the step that saves you: those values are the configuration you would otherwise have to remember.&lt;/li&gt;
&lt;li&gt;Commit two things: the recovered values at &lt;code&gt;kubernetes/infra/&amp;lt;domain&amp;gt;/&amp;lt;component&amp;gt;/values.yaml&lt;/code&gt;, and a child Application in &lt;code&gt;kubernetes/clusters/devata/&lt;/code&gt;. The Application uses Argo CD's multi-source pattern: one source is the upstream chart repository with a pinned version, the other is my git repo providing the values file.&lt;/li&gt;
&lt;li&gt;Automation stays &lt;strong&gt;off&lt;/strong&gt; at first. The Application appears in Argo CD, but nothing syncs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;argocd app diff &amp;lt;name&amp;gt;&lt;/code&gt; until the diff is empty. Every line in that diff is a discrepancy between what git declares and what is running, and each one gets fixed in git, not in the cluster.&lt;/li&gt;
&lt;li&gt;Flip automation on (&lt;code&gt;selfHeal: true&lt;/code&gt;, &lt;code&gt;prune: true&lt;/code&gt;), then drift-test it: change something trivial with &lt;code&gt;kubectl&lt;/code&gt; and watch the reconciler put it back.&lt;/li&gt;
&lt;li&gt;Retire the Helm release record. Helm stores its bookkeeping as a Secret named &lt;code&gt;sh.helm.release.v1.&amp;lt;release&amp;gt;.v&amp;lt;N&amp;gt;&lt;/code&gt; in the release namespace. Deleting it removes the component from &lt;code&gt;helm list&lt;/code&gt; for good. Helm no longer owns this component, git does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the first multi-source Application I wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/clusters/devata/nvidia-device-plugin.yaml&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://nvidia.github.io/k8s-device-plugin&lt;/span&gt;
      &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-device-plugin&lt;/span&gt;
      &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.19.3&lt;/span&gt;  &lt;span class="c1"&gt;# pinned; upgrading is editing this line&lt;/span&gt;
      &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;valueFiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;$values/kubernetes/infra/controllers/nvidia-device-plugin/values.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/PragalvaXFREZ/lab.git&lt;/span&gt;
      &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;values&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second source contributes no manifests; &lt;code&gt;ref: values&lt;/code&gt; just makes my repo addressable as &lt;code&gt;$values&lt;/code&gt;, so the chart from NVIDIA's repo renders with the values file from mine.&lt;/p&gt;

&lt;p&gt;&lt;span&gt;Lesson&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;Name the Application after the &lt;em&gt;release&lt;/em&gt;, not the chart. Charts embed the release name into resource names: my kube-prometheus-stack release is &lt;code&gt;kps&lt;/code&gt;, so the resources are &lt;code&gt;kps-grafana&lt;/code&gt;, &lt;code&gt;kps-operator&lt;/code&gt;, and so on. If I had named the Application &lt;code&gt;kube-prometheus-stack&lt;/code&gt;, Argo CD would have rendered a second, parallel stack with new names, and the diff could never reach empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each component taught me
&lt;/h3&gt;

&lt;p&gt;The drill was the same six times, but every component had its own personality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nvidia-device-plugin&lt;/strong&gt; went first as the safe rehearsal: a single DaemonSet, four lines of values, and (since the GPU in that node turned out to have a hardware fault) a component that advertises nothing anyway. Perfect crash test dummy. Its diff was empty on the very first render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;kube-prometheus-stack&lt;/strong&gt; was the first component that was not plain, in two ways. Its CRDs are enormous, so like Argo CD itself it needs &lt;code&gt;ServerSideApply=true&lt;/code&gt;. And the recovered values had a surprise in them: &lt;code&gt;helm get values kps&lt;/code&gt; returned the Grafana admin password in plaintext. It had been sitting in the Helm release secret the whole time. That password never entered git. It moved into a manually created &lt;code&gt;grafana-admin&lt;/code&gt; Secret in the cluster, &lt;code&gt;values.yaml&lt;/code&gt; points the chart at it through &lt;code&gt;grafana.admin.existingSecret&lt;/code&gt;, and retiring the Helm release records at the end of the drill had a satisfying side effect: it destroyed the plaintext copies. Sealing that Secret into git properly, encrypted, is the sealed-secrets workstream that comes next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;loki&lt;/strong&gt; and &lt;strong&gt;promtail&lt;/strong&gt; were the routine reps: recover values, commit, diff to empty, flip automation. By this point the drill felt mechanical, which is exactly what you want from your logging stack migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;metallb&lt;/strong&gt; is tracked as the upstream native manifests plus my address-pool config rather than a chart, and it introduced the next class of problem: fields that are &lt;em&gt;supposed&lt;/em&gt; to differ from git. The controller injects &lt;code&gt;caBundle&lt;/code&gt; certificates into its webhook configurations at runtime. Git says the field is empty, the cluster says it is a certificate, and that diff will never close because it is not drift, it is the component working as designed. The answer is &lt;code&gt;ignoreDifferences&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ignoreDifferences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiextensions.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CustomResourceDefinition&lt;/span&gt;
    &lt;span class="na"&gt;jqPathExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.spec.conversion.webhook.clientConfig.caBundle&lt;/span&gt;
&lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RespectIgnoreDifferences=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RespectIgnoreDifferences=true&lt;/code&gt; matters: without it, ignored fields still get overwritten on sync. With it, Argo CD neither reports them as drift nor touches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cilium&lt;/strong&gt; went last, deliberately. It is the CNI; if adopting it goes wrong, every pod on the cluster loses networking, including Argo CD itself. And it had the hardest version of the metallb problem: the chart's default &lt;code&gt;hubble.tls.auto.method=helm&lt;/code&gt; generates &lt;em&gt;fresh&lt;/em&gt; Hubble TLS certificates on every render. So &lt;code&gt;cilium-ca&lt;/code&gt; and both Hubble cert Secrets differed on every single diff, and with self-heal on, the reconciler would have rotated Hubble's TLS forever, once per sync. Two clean options existed: switch cert generation to the chart's cronJob method (Cilium's documented GitOps pattern), or &lt;code&gt;ignoreDifferences&lt;/code&gt; on the three Secrets. I went with &lt;code&gt;ignoreDifferences&lt;/code&gt; plus &lt;code&gt;RespectIgnoreDifferences&lt;/code&gt;, the same shape as metallb: the certs are runtime state that Helm happened to generate, not configuration I want git to own. Diff to empty, automation on, &lt;code&gt;sh.helm.release.v1.cilium.v3&lt;/code&gt; deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  The proof
&lt;/h3&gt;

&lt;p&gt;With few pull requests and tinkering done here and there, I was able to get it done at the end: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fgitops-helm-list-empty.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpragalva.me%2Fblog%2Fimages%2Fposts%2Fgitops-helm-list-empty.png" alt="A terminal running helm list -A and returning only the header row: no Helm releases remain on the cluster." width="765" height="86"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;&lt;b&gt;Figure 1.&lt;/b&gt; &lt;code&gt;helm list -A&lt;/code&gt; on devata returns nothing. All of the applications have been migrated to be declarative with the help of Argo CD.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;An empty &lt;code&gt;helm list -A&lt;/code&gt; does not mean Helm is gone; the charts are still rendered by Helm, inside Argo CD. What is gone is Helm as the &lt;em&gt;owner&lt;/em&gt; of state on my cluster. The owner is git now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ argocd app list
NAME                  SYNC    HEALTH
argocd                Synced  Healthy
cilium                Synced  Healthy
devata-root           Synced  Healthy
hello                 Synced  Healthy
kps                   Synced  Healthy
loki                  Synced  Healthy
metallb               Synced  Healthy
nvidia-device-plugin  Synced  Healthy
promtail              Synced  Healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those is a file in the repo. Upgrading a chart is editing one pinned version line. Changing a dashboard is a pull request. And if devata dies tomorrow, the recovery plan is no longer "reinstall from memory", it is: build the machines with &lt;code&gt;talosctl&lt;/code&gt;, apply two manifests, and watch Argo CD rebuild everything else from git. Pretty cool, huh ?&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://pragalva.me/blog/how-i-made-my-homelab-declarative/" rel="noopener noreferrer"&gt;pragalva.me&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
