<?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: Kate</title>
    <description>The latest articles on DEV Community by Kate (@keito118).</description>
    <link>https://dev.to/keito118</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%2F4171721%2F6419af76-53aa-4b0c-9da7-103ebfa89f36.png</url>
      <title>DEV Community: Kate</title>
      <link>https://dev.to/keito118</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/keito118"/>
    <language>en</language>
    <item>
      <title>Why my from-scratch Japanese LLM says "That sounds tough" to good news</title>
      <dc:creator>Kate</dc:creator>
      <pubDate>Thu, 08 Oct 2026 16:12:18 +0000</pubDate>
      <link>https://dev.to/keito118/why-my-from-scratch-japanese-llm-says-that-sounds-tough-to-good-news-3k15</link>
      <guid>https://dev.to/keito118/why-my-from-scratch-japanese-llm-says-that-sounds-tough-to-good-news-3k15</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Lilas is a ~40M-parameter Japanese chat model I built from scratch, tokenizer included. It kept answering cheerful remarks with "That sounds tough, please don't overdo it." A raw count of the training data pointed at one over-used phrase. Weighting the counts by how the data is actually mixed at training time changed the picture. The real gap is that the plain positive reply, "I'm glad to hear that" (それは良かった), is the rarest positive reaction the model sees, 3–5x rarer than the other positive replies and 4x rarer than "That sounds tough."&lt;/p&gt;

&lt;p&gt;This post covers the diagnosis and the fix plan. The retraining has not been run yet, so there are no after-numbers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code and data: &lt;a href="https://github.com/keito118/lilas-llm-from-scratch" rel="noopener noreferrer"&gt;github.com/keito118/lilas-llm-from-scratch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Me: &lt;a href="https://github.com/keito118" rel="noopener noreferrer"&gt;github.com/keito118&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Background: what Lilas is
&lt;/h2&gt;

&lt;p&gt;Lilas is a hobby project to build a Japanese conversational AI without any pretrained model. Everything runs on one consumer GPU (RTX 3060, 12 GB).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;GPT-style decoder, ~40M parameters (n_embd=512, n_layer=10, n_head=8, block_size=1024)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokenizer&lt;/td&gt;
&lt;td&gt;Own byte-level BPE, vocab 16,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pretraining&lt;/td&gt;
&lt;td&gt;Aozora Bunko + Wikipedia excerpts + public instruction data, ~960M characters (~610M tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-tuning&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;User:&lt;/code&gt; / &lt;code&gt;Lilas:&lt;/code&gt; dialogue files in &lt;a href="https://github.com/keito118/lilas-llm-from-scratch/tree/main/index" rel="noopener noreferrer"&gt;&lt;code&gt;index/&lt;/code&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;Calculator, date, holidays, weather and Wikipedia lookups run in code and are injected into the prompt as reference text (RAG)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One detail matters for this post. The hand-made persona and dialogue files are tiny next to the public instruction data, so &lt;a href="https://github.com/keito118/lilas-llm-from-scratch/blob/main/lilas/data.py" rel="noopener noreferrer"&gt;&lt;code&gt;lilas/data.py&lt;/code&gt;&lt;/a&gt; copies them &lt;code&gt;persona_repeat&lt;/code&gt; times into the training mix. The production model ("round2") used &lt;code&gt;persona_repeat=21&lt;/code&gt;. Without this, the model forgot its own name.&lt;/p&gt;

&lt;p&gt;The production model was picked after eight rounds of experiments in one day, logged in &lt;a href="https://github.com/keito118/lilas-llm-from-scratch/blob/main/SCALE_UP_PLAN.md" rel="noopener noreferrer"&gt;&lt;code&gt;SCALE_UP_PLAN.md&lt;/code&gt;&lt;/a&gt;. The common lesson of those rounds: adding data helps only near the training examples and rarely generalizes to new phrasings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A typical failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:  今日は天気がいいから洗濯物がよく乾きそう
       (The weather's nice today, so the laundry should dry well.)
Lilas: それは大変そうですね、無理なさらないでください
       (That sounds tough. Please don't overdo it.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was not a one-off. The held-out evaluations had been showing the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emotion-matching accuracy rose from 30–40% to 66.7%, but about a third still failed.&lt;/strong&gt; The most common failure was a flipped polarity: a cheerful reply to bad news, or the reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding more emotion data (round 8) did not help with close paraphrases.&lt;/strong&gt; Training on "They sent my draft back for a rewrite" did nothing for "They told me to rewrite it again." Asked "Looking forward to the party?", the model started talking about curry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of adding more data again, I counted what was already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  First pass: raw counts
&lt;/h2&gt;

&lt;p&gt;The first pass took three steps and no training at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find where the stock phrases come from.&lt;/strong&gt; Grepping the data-generation scripts for common reaction phrases turned up five generators (emotion, greetings, long-context, arithmetic and seed dialogues) that all reuse the same handful of replies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count them.&lt;/strong&gt; &lt;code&gt;grep -o &amp;lt;phrase&amp;gt; index/*.txt | wc -l&lt;/code&gt; for each phrase over the full local training set (20 files, 68,955 &lt;code&gt;Lilas:&lt;/code&gt; lines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check how the files are mixed.&lt;/strong&gt; Reading &lt;code&gt;lilas/data.py&lt;/code&gt; confirmed which files are copied &lt;code&gt;persona_repeat&lt;/code&gt; times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The headline number: "That sounds tough" (それは大変) appeared &lt;strong&gt;364&lt;/strong&gt; times, while "I'm glad to hear that" (それは良かった) appeared only &lt;strong&gt;25&lt;/strong&gt; times, about 15x fewer.&lt;/p&gt;

&lt;p&gt;The first hypothesis followed directly. "That sounds tough" is the model's default escape hatch, and &lt;code&gt;persona_repeat&lt;/code&gt; multiplies it further. That turned out to be half right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second pass: count what the model actually sees
&lt;/h2&gt;

&lt;p&gt;Raw file counts are not what the model trains on, so I redid the count with each file multiplied by its real factor. The result overturned half of the first hypothesis.&lt;/p&gt;

&lt;p&gt;The public repo holds only the curated dialogue files (16 files, 3,971 &lt;code&gt;Lilas:&lt;/code&gt; lines). The large public instruction files stay local. Of the 364 "That sounds tough" hits, only 132 are in the curated files. &lt;strong&gt;102 of those 132 come from a single template generator&lt;/strong&gt; (&lt;a href="https://github.com/keito118/lilas-llm-from-scratch/blob/main/index/generated_longcontext_ja.txt" rel="noopener noreferrer"&gt;&lt;code&gt;generated_longcontext_ja.txt&lt;/code&gt;&lt;/a&gt;). The other 232 sit in the large instruction files, which are not upsampled.&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%2F2hfyw1bveegrrcgvrnuc.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%2F2hfyw1bveegrrcgvrnuc.png" alt="In the real training mix, " width="799" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Raw: grep over the full local training set (20 files). Weighted: repo files × their factor in lilas/data.py, plus the 232 non-repo occurrences ×1.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After weighting, "That sounds tough" drops from 31% of the reaction phrases to 17% and is no longer the most common. "I'm glad to hear that" stays the rarest positive reply. The upsampling did amplify something, just not the phrase I first suspected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revised hypothesis
&lt;/h2&gt;

&lt;p&gt;The problem is not that one negative phrase dominates. It is that the model has almost no plain way to say "good for you."&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The small-good-news reply is missing.&lt;/strong&gt; "I'm looking forward to it" belongs to future plans. "That's wonderful" belongs to achievements. "I'm glad to hear that" fits everyday small wins like dry laundry, and it is the rarest positive reply in the training mix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"That sounds tough" is a fixed two-sentence chunk.&lt;/strong&gt; 43 of the 132 &lt;code&gt;Lilas:&lt;/code&gt; lines containing it also say "please don't overdo it" (無理なさらないで). The failing reply is exactly that chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The templates attach it to anything that isn't clearly happy.&lt;/strong&gt; In the long-context generator, 65 of the 102 "tough" replies follow just two user lines (spilling coffee, a late train). The other 37 are the same "sounds tough, don't overdo it" line attached to mostly neutral remarks such as "I took my watch in for repair" or "I'm thinking of getting a massage." A small model can learn "anything that isn't clearly happy gets sympathy" without learning polarity at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When polarity is unclear, the model falls back to a high-frequency chunk.&lt;/strong&gt; With no generic positive acknowledgement to fall back on, the negative chunk wins. This step is still a guess, and the retraining is where it gets tested.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This also explains why round 8 failed. More emotion examples written in the same style reproduce the same gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;Five steps, roughly 6–9 hours. The order comes from what failed in earlier rounds.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory conversational moves (0.5–1 h).&lt;/strong&gt; A script that counts reactions, back-channel replies, topic changes, clarifying questions and follow-up questions per file, so gaps are visible as numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add polarity-matching data (2–3 h).&lt;/strong&gt; Examples where the reply depends on whether the input is positive, negative or neutral, with "I'm glad to hear that" filled in across many topics. Neutral remarks get neutral replies, not sympathy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill other thin conversational skills (1–2 h)&lt;/strong&gt; found in step 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a polarity-mismatch check to the eval (1 h).&lt;/strong&gt; Flag a negative reaction to a positive input automatically, not just by eye.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrain from the clean pretrained checkpoint and compare on held-out conversations (1.5–3 h)&lt;/strong&gt; against the production model, with a fixed seed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Lessons carried over from earlier rounds
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened before&lt;/th&gt;
&lt;th&gt;How it shapes this plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing on conversations close to the training data made memorization look like generalization&lt;/td&gt;
&lt;td&gt;Decide only on a frozen held-out set that never enters training (&lt;a href="https://github.com/keito118/lilas-llm-from-scratch/tree/main/eval" rel="noopener noreferrer"&gt;&lt;code&gt;eval/&lt;/code&gt;&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Widening a recall task from 8 to 32 topics made it work 8 times in a row on unseen names&lt;/td&gt;
&lt;td&gt;Use the same topic × slot recipe, with wide topic coverage, for polarity data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training on 27, then 97, then 70 fixed facts kept blending entities together&lt;/td&gt;
&lt;td&gt;Avoid a few memorized "answers"; spread reactions over many phrasings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Making only the model bigger made it worse&lt;/td&gt;
&lt;td&gt;Keep the model size fixed and change only the data distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run-to-run noise hid real effects&lt;/td&gt;
&lt;td&gt;Fix the random seed for every comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A small model repeats the habits of its data.&lt;/strong&gt; Before blaming model capacity, grep the corpus. The whole diagnosis took minutes of counting and zero GPU time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count what the model actually trains on.&lt;/strong&gt; Raw file counts and the weighted training mix told different stories. Upsampling changes which phrases dominate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the shape, not the totals.&lt;/strong&gt; Positive replies were never scarce overall: they match negative-leaning ones in the raw files and outnumber them in the training mix. Stopping at "too much negativity" would have led to deleting sympathy data, which fixes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template generators leak their defaults.&lt;/strong&gt; Most of the "tough" replies come from one generator, often attached to neutral remarks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The counts are substring matches. They do not separate &lt;code&gt;User:&lt;/code&gt; lines from &lt;code&gt;Lilas:&lt;/code&gt; lines, except where noted.&lt;/li&gt;
&lt;li&gt;The 232 occurrences outside the repo files are assumed to be used once (they are not upsampled by &lt;code&gt;persona_repeat&lt;/code&gt; in &lt;code&gt;lilas/data.py&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The repo holds the latest version of the dialogue files, which may differ slightly from what the production model trained on. That this distribution causes the bug is still a hypothesis. Step 5 is the test. I will add the results to this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reproduce
&lt;/h3&gt;

&lt;p&gt;From a clone of the &lt;a href="https://github.com/keito118/lilas-llm-from-scratch" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, the curated files can be counted like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;それは大変 それは良かった それは楽しみ&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;index/&lt;span class="k"&gt;*&lt;/span&gt;.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply each file's count by its factor from &lt;code&gt;lilas/data.py&lt;/code&gt; (&lt;code&gt;persona_repeat=21&lt;/code&gt; for the persona files, ×3 extra for knowledge files, ×5 extra for greetings) to get the weighted numbers.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>llm</category>
      <category>python</category>
      <category>japanese</category>
    </item>
  </channel>
</rss>
