<?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>My Spotify playlist takes pull requests</title>
      <dc:creator>Kate</dc:creator>
      <pubDate>Sat, 17 Oct 2026 18:44:18 +0000</pubDate>
      <link>https://dev.to/keito118/my-spotify-playlist-takes-pull-requests-324h</link>
      <guid>https://dev.to/keito118/my-spotify-playlist-takes-pull-requests-324h</guid>
      <description>&lt;p&gt;What if a playlist worked like a repository?&lt;/p&gt;

&lt;p&gt;You'd propose a song with a pull request. CI would check that it's a real track and not a duplicate. Review would mean &lt;em&gt;listening to it&lt;/em&gt;. Merging would publish it. And &lt;code&gt;git log&lt;/code&gt; would tell you who added every song, and why.&lt;/p&gt;

&lt;p&gt;So I built that. It's two repos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🎧 &lt;strong&gt;&lt;a href="https://github.com/keito118/open-source-mixtape" rel="noopener noreferrer"&gt;Open Source Mixtape&lt;/a&gt;&lt;/strong&gt;: a public Spotify playlist that anyone with a GitHub account can add to. &lt;strong&gt;&lt;a href="https://open.spotify.com/playlist/6RrEtS1qX6csamOV6ET74Q" rel="noopener noreferrer"&gt;Listen on Spotify&lt;/a&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;🛠 &lt;strong&gt;&lt;a href="https://github.com/keito118/spotify-playlist-as-code" rel="noopener noreferrer"&gt;spotify-playlist-as-code&lt;/a&gt;&lt;/strong&gt;: the template it runs on. Click &lt;em&gt;Use this template&lt;/em&gt; and your own playlist takes PRs too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No server, no database, no dependencies beyond the Python standard library. Just a text file, GitHub Actions, and the Spotify Web API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole playlist is one text file
&lt;/h2&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%2Fpyxs66204bi7ihw2b3tx.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%2Fpyxs66204bi7ihw2b3tx.png" alt="The playlist file: a header with the Spotify playlist ID, then one song per line" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's it. One line per song, in playlist order. The link can be a &lt;code&gt;spotify:track:&lt;/code&gt; URI, an &lt;code&gt;open.spotify.com&lt;/code&gt; link (with or without &lt;code&gt;?si=…&lt;/code&gt; or &lt;code&gt;/intl-ja/&lt;/code&gt;), or a bare 22-character ID. The &lt;code&gt;# Artist - Title&lt;/code&gt; after it is for humans, so reviewers know what they're about to hear.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;# @playlist&lt;/code&gt; line says which Spotify playlist the file syncs to. A file without it still gets validated, it just never touches Spotify, which is handy for trying things out.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a pasted link to a live playlist
&lt;/h2&gt;

&lt;p&gt;Asking strangers to fork a repo and edit a file to suggest a song is a lot. So the front door is an issue form that asks for a single thing: a Spotify link.&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%2Fj4vimja5akzt16t9fgss.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%2Fj4vimja5akzt16t9fgss.png" alt="Flow: open an issue, a bot opens the PR, CI checks it, the maintainer listens and merges, then the playlist syncs to Spotify" width="800" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Someone opens a "🎵 Add a song" issue&lt;/strong&gt; and pastes a link. "Why this one?" and "Listening from" are optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A workflow turns the issue into a PR.&lt;/strong&gt; It looks the song up on Spotify, appends &lt;code&gt;&amp;lt;link&amp;gt;  # Artist - Title&lt;/code&gt; to the file, and comments back on the issue with the PR link. If the link is an album, or the song is already in the playlist, it says so on the issue and closes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI validates the PR.&lt;/strong&gt; Malformed IDs and duplicates fail the check with an annotation on the exact line. Every added track gets looked up, and the job summary reads like a tracklist: &lt;code&gt;➕ KiiiKiii - Pop Off Pop Off&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review is listening.&lt;/strong&gt; I play the song, then merge, comment, or politely pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merging is publishing.&lt;/strong&gt; A push to &lt;code&gt;main&lt;/code&gt; that touches &lt;code&gt;playlists/&lt;/code&gt; runs the sync, and the Spotify playlist now matches the file.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Five things I learned building it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Make the sync declarative
&lt;/h3&gt;

&lt;p&gt;The sync doesn't figure out "add this, remove that". It replaces the playlist with whatever the file says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;replace_playlist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uris&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Make the playlist contain exactly `uris`, in order.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uris&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;uris&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/playlists/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;playlist_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/playlists/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;playlist_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PUT&lt;/code&gt; sets the first 100 tracks and &lt;code&gt;POST&lt;/code&gt; appends the rest in batches of 100. Reorders, removals and reverts all come for free, and a failed run is fixed by just running it again. The trade-off is in the README in a big warning box: anything added straight in the Spotify app disappears on the next sync. The file wins. That's the point.&lt;/p&gt;

&lt;p&gt;(If you've used the Spotify API before: these are the &lt;code&gt;/items&lt;/code&gt; endpoints from the February 2026 Web API changes. The old &lt;code&gt;/tracks&lt;/code&gt; paths are gone.)&lt;/p&gt;

&lt;h3&gt;
  
  
  2. PRs opened by &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; don't trigger your other workflows
&lt;/h3&gt;

&lt;p&gt;This one cost me a while. The bot opens its PR with the built-in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, and GitHub deliberately doesn't start new workflow runs from events caused by that token, so &lt;code&gt;on: pull_request&lt;/code&gt; never fired and the bot's PRs had no check at all.&lt;/p&gt;

&lt;p&gt;The fix is to give the validate workflow a &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger and have the bot start it by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--base&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEFAULT_BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--head&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Add &lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--body-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PR_BODY_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Pull requests opened with GITHUB_TOKEN don't trigger other workflows, so start the check here.&lt;/span&gt;
gh workflow run validate.yml &lt;span class="nt"&gt;--ref&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need &lt;strong&gt;Settings → Actions → General → Allow GitHub Actions to create and approve pull requests&lt;/strong&gt;, or &lt;code&gt;gh pr create&lt;/code&gt; fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Give the human credit, not the bot
&lt;/h3&gt;

&lt;p&gt;The commit is made by &lt;code&gt;github-actions[bot]&lt;/code&gt;, but the song came from whoever opened the issue. So the commit message carries a co-author trailer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add &lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Suggested in #&lt;/span&gt;&lt;span class="nv"&gt;$NUMBER&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Co-authored-by: &lt;/span&gt;&lt;span class="nv"&gt;$AUTHOR&lt;/span&gt;&lt;span class="s2"&gt; &amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;$AUTHOR_ID&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="nv"&gt;$AUTHOR&lt;/span&gt;&lt;span class="s2"&gt;@users.noreply.github.com&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the &lt;code&gt;ID+login@users.noreply.github.com&lt;/code&gt; form, GitHub links the commit to their profile. Adding a song to a playlist shows up as a real contribution.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Git history makes great liner notes
&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%2Fp4l9p19eiqnnk2ngetvb.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%2Fp4l9p19eiqnnk2ngetvb.png" alt="git log of the playlists folder: who added each song" width="800" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I didn't build any of this. &lt;code&gt;git log -p playlists/&lt;/code&gt; already answers "who added this song, when, and why", and &lt;code&gt;git revert&lt;/code&gt; undoes a bad pick. Version control turns out to be a pretty good music library.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Plan for forks without secrets
&lt;/h3&gt;

&lt;p&gt;Pull requests from forks don't get repository secrets, so CI can't call Spotify for them. Rather than failing, the validator skips the online lookup and still checks the format and duplicates. The sync does the same: no secrets, so it runs as a dry run. A fresh copy of the template is green on day one before you've set anything up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make your own
&lt;/h2&gt;

&lt;p&gt;Want this for your team's office playlist, a party queue, or a road trip with friends?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Use this template&lt;/strong&gt; on &lt;a href="https://github.com/keito118/spotify-playlist-as-code" rel="noopener noreferrer"&gt;spotify-playlist-as-code&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Create a Spotify app, get a refresh token with &lt;code&gt;python scripts/get_refresh_token.py&lt;/code&gt;, and add three repository secrets.&lt;/li&gt;
&lt;li&gt;Put your playlist link in a &lt;code&gt;# @playlist&lt;/code&gt; line and merge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full steps are in the README. One heads-up: Spotify's development mode needs the app owner to have Premium. People adding songs don't need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a song to the mixtape 🎶
&lt;/h2&gt;

&lt;p&gt;The mixtape is small right now, so your pick will stand out. Any genre, any language, any decade.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/keito118/open-source-mixtape/issues/new?template=add-song.yml" rel="noopener noreferrer"&gt;Open an "Add a song" issue&lt;/a&gt;&lt;/strong&gt; and paste a Spotify link. The bot does the rest.&lt;/p&gt;

&lt;p&gt;Then tell me in the comments what you added and why. And if you have an idea for the tool itself, issues and PRs are welcome on &lt;a href="https://github.com/keito118/spotify-playlist-as-code" rel="noopener noreferrer"&gt;spotify-playlist-as-code&lt;/a&gt;. ⭐ Stars help other people find it.&lt;/p&gt;

</description>
      <category>github</category>
      <category>githubactions</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <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>
