<?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: jack</title>
    <description>The latest articles on DEV Community by jack (@jack_e2a413ec5d0bba216).</description>
    <link>https://dev.to/jack_e2a413ec5d0bba216</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%2F4137922%2F418719d4-7c90-44de-8fe1-5751972dd457.jpg</url>
      <title>DEV Community: jack</title>
      <link>https://dev.to/jack_e2a413ec5d0bba216</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jack_e2a413ec5d0bba216"/>
    <language>en</language>
    <item>
      <title>Unicode normalization is not a deduplication key</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Tue, 22 Sep 2026 17:29:47 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/unicode-normalization-is-not-a-deduplication-key-4c6d</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/unicode-normalization-is-not-a-deduplication-key-4c6d</guid>
      <description>&lt;p&gt;I had 84,000 kaomoji to deduplicate and a key that looked obviously correct:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;key = "".join(unicodedata.normalize("NFKC", text).split())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Strip the whitespace, normalize the Unicode, group by the result. Every&lt;br&gt;
deduplication tutorial I have ever read says to normalize first. It took me one&lt;br&gt;
afternoon of reviewing the output to realize that this key had quietly decided&lt;br&gt;
&lt;code&gt;(╥_╥)&lt;/code&gt; and &lt;code&gt;(╥﹏╥)&lt;/code&gt; were the same record.&lt;/p&gt;

&lt;p&gt;They are two different faces. The wavy mouth is a harder cry than the flat one.&lt;/p&gt;
&lt;h2&gt;
  
  
  NFKC folds four characters that matter here
&lt;/h2&gt;

&lt;p&gt;NFKC is &lt;em&gt;compatibility&lt;/em&gt; normalization. The K stands for compatibility, and the&lt;br&gt;
point of it is to fold characters that exist in Unicode only for round-tripping&lt;br&gt;
with older encodings: the fullwidth Latin letters, the halfwidth katakana, the&lt;br&gt;
circled numbers, the ligatures. It maps them onto their "normal" equivalents so&lt;br&gt;
that &lt;code&gt;ｈｅｌｌｏ&lt;/code&gt; and &lt;code&gt;hello&lt;/code&gt; compare equal.&lt;/p&gt;

&lt;p&gt;That is correct when the text is prose and you want &lt;code&gt;Ｅ－ｍａｉｌ&lt;/code&gt; to match&lt;br&gt;
&lt;code&gt;E-mail&lt;/code&gt;. It stops being correct once the characters are the content themselves.&lt;br&gt;
Here is what it does to four characters that carry meaning in a kaomoji:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Codepoint&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;NFKC result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;U+FE4F&lt;/td&gt;
&lt;td&gt;WAVY LOW LINE &lt;code&gt;﹏&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_&lt;/code&gt; (U+005F)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+FF3C&lt;/td&gt;
&lt;td&gt;FULLWIDTH REVERSE SOLIDUS &lt;code&gt;＼&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;\&lt;/code&gt; (U+005C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+FF64&lt;/td&gt;
&lt;td&gt;HALFWIDTH IDEOGRAPHIC COMMA &lt;code&gt;､&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;、&lt;/code&gt; (U+3001)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+3000&lt;/td&gt;
&lt;td&gt;IDEOGRAPHIC SPACE &lt;code&gt;　&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&lt;/code&gt; (U+0020)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first one is the crying face. &lt;code&gt;﹏&lt;/code&gt; is a wavy mouth and &lt;code&gt;_&lt;/code&gt; is a flat one, and&lt;br&gt;
NFKC folds them together because one of them was originally a typesetting variant&lt;br&gt;
of the other. The second one is a pair of arms. &lt;code&gt;＼(^o^)／&lt;/code&gt; is a wide celebratory&lt;br&gt;
shrug, &lt;code&gt;\(^o^)/&lt;/code&gt; is a narrow one, and after NFKC they are the same string.&lt;/p&gt;

&lt;p&gt;The third one is a case where NFKC is &lt;em&gt;right&lt;/em&gt;. A fullwidth and a halfwidth&lt;br&gt;
ideographic comma really are the same punctuation mark, and &lt;code&gt;(≖、≖╬)&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;(≖､≖╬)&lt;/code&gt; really are one entry. That is what makes this hard. The normalization&lt;br&gt;
is not simply wrong. It is right about maybe a third of what it touches, and you&lt;br&gt;
can't tell which third without looking.&lt;/p&gt;
&lt;h2&gt;
  
  
  233 proposed merges, or 6,944
&lt;/h2&gt;

&lt;p&gt;This is easy to measure on a published dataset, so you do not have to take my word&lt;br&gt;
for it. The file below is the merged corpus, 82,109 entries, already deduplicated&lt;br&gt;
on exact string match. Every collision found here is a &lt;em&gt;proposed&lt;/em&gt; merge between&lt;br&gt;
two entries that a human previously decided were different.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyfn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/kaomoji.jsonl.gz&lt;/span&gt;&lt;span class="sh"&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;rt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;keyfn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&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;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&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;g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sum&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;v&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;strip_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;with_nfkc&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip_only&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# (233, 500)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_nfkc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# (6944, 15038)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripping whitespace alone proposes 233 merges. Adding NFKC proposes 6,944,&lt;br&gt;
covering 15,038 of the 82,109 entries. Auto-merging on that key deletes about&lt;br&gt;
8,000 records, and you never find out which ones, because a merge leaves no&lt;br&gt;
trace.&lt;/p&gt;

&lt;p&gt;Some of what it finds is genuinely useful. These are real groups from that run:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(╥_╥)              (╥﹏╥)
(・∀・)ノ            (･∀･)ノ
(╯°□°)╯︵ ┻━┻       (╯°□°）╯︵ ┻━┻
(T_T)              (T＿T)          (Ｔ＿Ｔ)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The second row is a real duplicate. Halfwidth versus fullwidth katakana middle&lt;br&gt;
dot is not something anyone chose, it is an artifact of which IME the author was&lt;br&gt;
typing in. The third row is a table flip where one of the two parentheses came&lt;br&gt;
out fullwidth, which is also an accident. The first row is two different faces.&lt;br&gt;
The fourth row is three faces if you care about stroke weight and one face if you&lt;br&gt;
don't.&lt;/p&gt;

&lt;p&gt;There is no rule that separates those four rows. I tried several: character&lt;br&gt;
class, width consistency within a single entry, whether the fold lands in the&lt;br&gt;
mouth region. All of them got the first row wrong in one direction or the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whitespace carries pose information
&lt;/h2&gt;

&lt;p&gt;Stripping whitespace has the same problem on a smaller scale, because whitespace&lt;br&gt;
is load-bearing in this data:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ʕ·ᴥ·ʔ          bear, facing you
ʕ·ᴥ·　ʔ         bear, looking left
ʕ　·ᴥ·ʔ         bear, looking right

( •_•)&amp;gt;⌐■-■    before the sunglasses
(•_•)&amp;gt;⌐■-■     also before the sunglasses, typed by someone else
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The first three are three entries. The last two are one. Whether a given space is&lt;br&gt;
a pose or a typo is not recoverable from the string, and I don't think it is&lt;br&gt;
recoverable at all without knowing what the thing depicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three keys, one of which is allowed to merge
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;key_exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;                       &lt;span class="c1"&gt;# byte-identical; safe to auto-merge
&lt;/span&gt;&lt;span class="n"&gt;key_loose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# whitespace only; PROPOSES merges
&lt;/span&gt;&lt;span class="n"&gt;key_nfkc&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# output field only
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;key_exact&lt;/code&gt; collisions merge automatically with no review. &lt;code&gt;key_loose&lt;/code&gt; collisions&lt;br&gt;
go to a human queue, and 233 groups is an afternoon. &lt;code&gt;key_nfkc&lt;/code&gt; is computed,&lt;br&gt;
stored as a field in the dataset, and never consulted by the merge pipeline.&lt;br&gt;
Consumers who want aggressive folding for search or for their own matching can&lt;br&gt;
have it. It just doesn't get to delete anything.&lt;/p&gt;

&lt;p&gt;That last key is the part I got wrong twice. My first instinct after it burned me&lt;br&gt;
was to rip it out entirely, which would have been the opposite mistake, because&lt;br&gt;
the folding is genuinely useful for recall. If somebody searches for &lt;code&gt;(╥﹏╥)&lt;/code&gt; you&lt;br&gt;
want the flat-mouthed one in the results too. So I kept computing it and took&lt;br&gt;
away its ability to destroy a record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the queue small enough that you will actually read it
&lt;/h2&gt;

&lt;p&gt;Normalization tells you which records might be the same. Whether they are the&lt;br&gt;
same is a different question, and I am&lt;br&gt;
not sure that one has an algorithmic answer.&lt;/p&gt;

&lt;p&gt;So normalize to find candidates, keep the raw string as the only identity, and&lt;br&gt;
put a human in front of anything that would destroy a record. If the human queue&lt;br&gt;
is too big to review, the candidate key is too aggressive and needs tightening.&lt;br&gt;
Nobody reviews 6,944 groups. They trust the key and move on, which is exactly how&lt;br&gt;
the records disappear without anyone noticing.&lt;/p&gt;




&lt;p&gt;The corpus is on GitHub with the raw JSONL and the verification scripts, and on&lt;br&gt;
npm and PyPI as &lt;code&gt;kaomoji-dataset&lt;/code&gt; if you just want the data:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm i kaomoji-dataset
pip install kaomoji-dataset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/Funovate/fontvibe-kaomoji" rel="noopener noreferrer"&gt;https://github.com/Funovate/fontvibe-kaomoji&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to look at the entries rather than the code, the browsable version is&lt;br&gt;
at &lt;a href="https://fontvibe.ai/tools/kaomoji" rel="noopener noreferrer"&gt;fontvibe.ai/tools/kaomoji&lt;/a&gt;. Data is CC BY&lt;br&gt;
4.0.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>datascience</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>I merged the seven public kaomoji collections. They barely overlap.</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:02:59 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/i-merged-the-seven-public-kaomoji-collections-they-barely-overlap-3jag</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/i-merged-the-seven-public-kaomoji-collections-they-barely-overlap-3jag</guid>
      <description>&lt;p&gt;I wanted a complete list of kaomoji, the &lt;code&gt;( ͡° ͜ʖ ͡°)&lt;/code&gt; and &lt;code&gt;¯\_(ツ)_/¯&lt;/code&gt; family. There isn't one.&lt;br&gt;
So I merged the public collections instead, and the merge turned up something I&lt;br&gt;
wasn't expecting: they hardly overlap at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Seven collections, 21 pairs, 0.3% median overlap
&lt;/h2&gt;

&lt;p&gt;The ones I found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/fdw/rofimoji" rel="noopener noreferrer"&gt;fdw/rofimoji&lt;/a&gt;, the biggest English-side set&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Allaman/emoji.nvim" rel="noopener noreferrer"&gt;Allaman/emoji.nvim&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/aoguai/rime_kaomoji_dict" rel="noopener noreferrer"&gt;aoguai/rime_kaomoji_dict&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mtripg6666tdr/Kaomoji_proj" rel="noopener noreferrer"&gt;mtripg6666tdr/Kaomoji_proj&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;the Japanese IME dictionaries&lt;/li&gt;
&lt;li&gt;kaosute&lt;/li&gt;
&lt;li&gt;an older list of my own&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across the 21 pairwise combinations the median Jaccard overlap is 0.3%. Four&lt;br&gt;
pairs share not a single entry. One pair clears 5%, rofimoji and emoji.nvim,&lt;br&gt;
which are both English-side Linux pickers, so that one is no surprise.&lt;/p&gt;

&lt;p&gt;The IME dictionaries are where most kaomoji actually live, and they share almost&lt;br&gt;
nothing with any of the English sets.&lt;/p&gt;

&lt;p&gt;Each entry in the merged file keeps the sources it came from, so you can check&lt;br&gt;
this yourself:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;

&lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/kaomoji.jsonl.gz&lt;/span&gt;&lt;span class="sh"&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;rt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&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;src&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&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;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&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;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combinations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# 0.003  0.079  4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the collections is close to complete, and after seeing those numbers I&lt;br&gt;
understand why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage checked at run time
&lt;/h2&gt;

&lt;p&gt;Saying "this has everything" is not worth much on its own, so&lt;br&gt;
&lt;code&gt;scripts/verify_coverage.py&lt;/code&gt; downloads each upstream file when you run it and&lt;br&gt;
diffs it against the merged set. Nothing is precomputed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Upstream&lt;/th&gt;
&lt;th&gt;Entries&lt;/th&gt;
&lt;th&gt;Covered&lt;/th&gt;
&lt;th&gt;Missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rofimoji&lt;/td&gt;
&lt;td&gt;1,562&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emoji.nvim&lt;/td&gt;
&lt;td&gt;2,016&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rime_kaomoji_dict&lt;/td&gt;
&lt;td&gt;959&lt;/td&gt;
&lt;td&gt;99.4%&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kaomoji_proj&lt;/td&gt;
&lt;td&gt;2,166&lt;/td&gt;
&lt;td&gt;97.8%&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It prints all 57 misses, so you can look at them and disagree with me about what&lt;br&gt;
counts as a kaomoji. Three rounds of that took rofimoji from 93.0% to 100.0%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Labels, and where they fall down
&lt;/h2&gt;

&lt;p&gt;Deduplicated it comes to 82,109 entries. Around 70k carry emotion, intent and&lt;br&gt;
subject labels from a controlled vocabulary in en, ja, zh, es, pt and de, so&lt;br&gt;
嬉しい, 开心, feliz and happy return the same entries instead of whichever English&lt;br&gt;
word the original author happened to use.&lt;/p&gt;

&lt;p&gt;Three things worth knowing before you use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The labels are coarse. 69,679 labelled entries share only 605 distinct label
combinations, because they were applied in bundles rather than judged one at a
time. Fine for pulling up a mood, not for finding one specific face.&lt;/li&gt;
&lt;li&gt;About 15% is left unlabelled rather than guessed at.&lt;/li&gt;
&lt;li&gt;Most of the corpus is 顔文字＋セリフ, a face plus a line of Japanese dialogue,
which is not much use if you cannot read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is why the English-language picker at&lt;br&gt;
&lt;a href="https://fontvibe.ai/tools/kaomoji" rel="noopener noreferrer"&gt;fontvibe.ai/tools/kaomoji&lt;/a&gt; shows 11,020, the&lt;br&gt;
subset that renders safely outside Japanese contexts, while the full 82,109 sits&lt;br&gt;
in the dataset and on the Japanese page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i kaomoji-dataset
pip &lt;span class="nb"&gt;install &lt;/span&gt;kaomoji-dataset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both read a gzip off disk, so they are Node and Python only for now. They will&lt;br&gt;
not bundle for a browser. The repo has the raw JSONL and a CSV as well:&lt;br&gt;
&lt;a href="https://github.com/Funovate/fontvibe-kaomoji" rel="noopener noreferrer"&gt;github.com/Funovate/fontvibe-kaomoji&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;106 of the entries are not collected from anywhere. I drew them, for feelings&lt;br&gt;
that had no kaomoji yet: 躺平, 社死, saudade, mamihlapinatapai. Those are CC0.&lt;/p&gt;

&lt;p&gt;If you know of a collection I missed, say so and I will run the coverage script&lt;br&gt;
against it.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>showdev</category>
      <category>python</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
