Linux/Mozc辞書データで漢字変換〜DB用コード

Mozcのdictionary01.txtを圧縮して、DBへ入れる例(2019-08-09)



前置き

基本的な漢字変換が行える情報ををMozc辞書から抜き出すことを目的としたコードの例になります。
前提となる 要件はこちら

DBサイズを極力小さくして、かつ1MB以下に分割する
といった、ややこしい処理については、
Androidうんぬんが不要なら削除しても良い部分です。

これでDBサイズが40MBくらいです。
テキストサイズで 56MB -> 33MBくらいに削減しています。
同じようなことをやってみたい、やらざるを得ない皆様の参考になれば幸いです。

shell

#!/bin/sh

# 事前に用意するものは以下
#  - dictionary.txt の一式 (dictionary00.txt から 09.txt)
#  - suffix.txt
#  - このshell。パーミッションを忘れずに。(chmod 744とか)
#  - wash_dic.pl (これも実行権限を付けて、perlのPATHは合わせる)
#  - ツールが出力先とするディレクトリは事前に mkdir しておく
#    db/  tsv/  の2つ
# 
# 事前にインストールが必要なものは、環境により異なりますが、
#  sqlite3コマンド、perlのUnicode::Japaneserライブラリ、など
# 
# その状態でこのshellを実行すると、 db/ に変換後のSQLite DB 一式と、
# そのDBの対応表となる case.txt が作成されます
#
#--- 以下、このshellの仕様の話 ----
# 漢字変換に使えるDBを作成する
#
# Androidアプリとして使用するために、1DBのサイズは1MB以下にする必要がある
#   - 分割したDBはAndroid java のコードで参照するので、
#     対応表を case.txt として出力する
#   - Androidアプリで使うのでなければ、この分割処理は不要
#
# dictionary.txt から必要な情報のみを拾い、検索の優先順位を並び替える
# (dictionary.txt:  "かな,左文脈ID,右文脈ID,コスト,漢字")
#   - 今回は文脈解釈は一切しない、コストのみを手がかりにする
#   - もっとも低いコストを採用してソート&マージする
#   - ただし人名、組織、地域、引用文字列(?)は優先度を下げる
#   - 漢字変換には要らなさそうな単語は削る
#
# ./wash_dic.pl で文字を洗う。
# - 変換前後で同じ文字列の場合は、変換後の文字は省略してサイズを減らす
# - コストを振り直す(127以下へ)
#
# dictionary99.txt を自作・追加した
# これを作成した 2019.06時の辞典では「令和」が無かったので
#    れいわ	1916	1916	1598	令和
#

echo "sort text files" #------------------------------------
cat dictionary0*.txt dictionary99.txt | sort -k1,1 -k5,5 -k4,4n > dic.dic 

echo "unique by src/dst"
awk '
BEGIN {
  L_SRC="src"
  L_IDL=0
  L_IDR=0
  L_COST="0"
  L_DST="dst"
}
{
  if($5 ~ /.*[!\?].*/ && $5 !~ /^[!\?]+$/){
     # !?を含む単語(!?のみは除く)は、ほぼ固有名詞なので除く
     # 予測変換が有効だと、生きてくるのだと思うが...
     next;
  }
 if($5 ~ /.*[・=].*/ && $5 !~ /^[・=]+$/ && $5 !~ /.*[^ぁ-んァ-ンヴ0-9ー・=].*$/){
     # おなじく、・=を含む単語は除く。漢字は除外(空港施設とか、地名との組み合わせとか)
     next;
  }
  if($1 == L_SRC && $5 == L_DST){
      n_cost=$4;
      if(1917 <= $2 && $2 <= 1926 && 1917 <= $3 && $3 <= 1926){
         # 人名(1917)〜引用文字列(1926)は優先順位を下げる
         n_cost=c_cost+1000000
      }
      # (人名以外で)より低いコストで上書きするが
      # 実動作と目視確認の結果、数字や接尾のコストが強すぎた
      #   名詞数字: 兆、澗、垓 はコストが0
      #   名詞接尾: 展(0, 4298)、産(1, 836, 5768) など
      # なので、複数かつコスト0/1の場合は、他方の数字を優先させる 
      if(L_COST > n_cost && n_cost > 1){
         L_COST=n_cost;
      }
      else if(L_COST < n_cost && L_COST <= 1){
         L_COST=n_cost;
      }
  }else{
      if(L_SRC == "src" && L_DST == "dst"){
          # 初期値だけは無視
      }else{
          if(L_SRC == L_DST){
              # 無変換の候補は最後に持ってくる
              L_COST=L_COST+2000000
          }
          printf("%s	%s	%d\n", L_SRC, L_DST, L_COST);
      }
      L_SRC=$1;
      L_COST=$4
      L_DST=$5
      if(1917 <= $2 && $2 <= 1926 && 1917 <= $3 && $3 <= 1926){
         # 人名(1917)〜引用文字列(1926)は下げる
         L_COST=L_COST+1000000
      }
  }
}' dic.dic > dic.dic.tmp

echo "sort again by cost" #--------------------------------
cat dic.dic.tmp  | sort -k1,1 -k3,3n -k2,2 > dic.dic.sort 

echo "make tsv file. (delete cost, add index)" #----------
awk '{
  printf("%d	%s	%s\n", NR, $1, $2);
}' dic.dic.sort  > dictionary.tsv

./wash_dic.pl dictionary.tsv # dictionary.washed を生成


echo "wash suffix.txt" #----------------------------------------
cat suffix.txt  |\
 sort -k1,1 -k5,5 -k4,4n |\
awk '
BEGIN {
  L_SRC="src"
  L_IDL=0
  L_IDR=0
  L_COST="0"
  L_DST="dst"
}
{
  if(length($1) == 1 && $1 != $5){
    # suffix による一文字変換は無視する
    # 19.06 現在の対象となる語は
    # ッ デ 無 ノ ヘ
    # 無(71:形容詞・イ段,ガル接続,無い) はsuffixを重ねる場合の想定だろうか?
    # "ッ、デ" は口語表現?今回はコスト計算しないので、対象から除外する
    # "ノ、ヘ" は片仮名か平仮名か漢字か、見分けがつかず混乱しやすいので除外
    next;
  }else if($1 == L_SRC && $5 == L_DST){
      n_cost=$4;
      if(L_COST > n_cost){
         # 文脈を問わず、より低いコストで上書きする
         L_COST=n_cost;
      }
  }else{
      if(L_SRC == "src" && L_DST == "dst"){
          # 初期値だけは無視
      }else{
          if(L_DST ~ /[ァ-ン]/){
              # カタカナの候補は最後に持ってくる
              # "です" よりも "デス" が優先される事象を避ける
              L_COST=L_COST+1000000
          }
          printf("%s	%s	%d\n", L_SRC, L_DST, L_COST);
      }
      L_SRC=$1;
      L_COST=$4
      L_DST=$5
  }
}' > suffix.tmp
cat suffix.tmp  | sort -k1,1 -k3,3n -k2,2 |\
awk '{
  printf("%d	%s	%s\n", NR, $1, $2);
}' > suffix.tsv
./wash_dic.pl suffix.tsv # suffix.washed を生成

echo "split tsv" #----------------------------------------
# 以下のファイル分割は、Androidアプリに入れるために1MB以下にする為の処理
rm tsv/*
awk '
BEGIN {
 INDEX="-";
 STATE="-";
 DB_NAME="none";
 DB_INDEX=0;
 OUT_FILE="none";
}
function openOk() {
    STATE="";
    DB_INDEX++;
    DB_NAME=sprintf("my_db%d", DB_INDEX);
    OUT_FILE=sprintf("tsv/%s", DB_NAME);
}
{
  if(substr($2, 1, 1) != INDEX){
    INDEX=substr($2, 1, 1);
    if(INDEX ~ /[あ-ゔ]/){ # 本当は"ヶ"の仮名が終端だけど、無視する
     if(INDEX ~ /ぁ/ || INDEX ~ /ぃ/ || INDEX ~ /ぅ/ || INDEX ~ /ぇ/ || INDEX ~ /ぉ/){
        # 2019.06時点では、小文字でめぼしいものはないので、無視する
        STATE="-";
      }else{
        openOk();
      }
    }
    else if(INDEX ~ /[ア-ヶ]/){ # "ヵ", "ヶ" を拾うのが目的
      # おそらく変換に使用しないけど、DB上は乗せておく
      openOk();
    }
    else if(INDEX ~ /[0-9]/){
      openOk();
    }
    else{
      STATE="-";
    }
    
    if(STATE == ""){
      printf("case \"%s\": return \"%s\";\n", INDEX, DB_NAME);
    }else{
      #printf("//%s%s: %s\n", STATE, INDEX, $0);
    }
  }
  if(STATE == ""){
    SUFF="";
    if(INDEX ~ /[ゑゐ0-9ゔがぎぐげござずぜぞっだづでぢどぬねのへばびぶべぼぱぴぷぺぽむめゆょらりるれろゎわをんヵヶ]/){
        # サイズが64KB以下になる文字は分けない
        SUFF="";
    }
    else if(length($2) <= 4){
      SUFF="_1234";
    }
    else if(INDEX ~ /[あいおかし]/){
        # 件数ではなく、単純にサイズの問題として
        # 分割しても 500KB を超える特定文字は, 5-9 文字をより細かく刻む
        if(length($2) <= 5){
          SUFF="_5";
        }else if(length($2) <= 6){
          SUFF="_6";
        }else if(length($2) <= 7){
          SUFF="_7";
        }else if(length($2) <= 8){
          SUFF="_8";
        }else if(length($2) <= 9){
          SUFF="_9";
        }
    }
    else if(length($2) <= 6){
      SUFF="_56";
    }
    else if(length($2) <= 8){
      SUFF="_78";
    }
    OFILE=sprintf("%s%s", OUT_FILE, SUFF);
    OFILE=sprintf("%s%s", OUT_FILE, SUFF);
    print $0 >> OFILE;
  }
}' dictionary.washed | tee case.txt

cp suffix.washed tsv/suffix

echo "create db" #--------------------------------------------------
rm db/*
for ff in `ls -1 tsv/*`; do
  SQDB=db/`basename $ff`
  echo "read $ff (-> $SQDB)"
  sqlite3 $SQDB " create table mozc (cost int, src text, dst text); "
  sqlite3 -separator "	" $SQDB ".import $ff mozc"
  # indexは 単純にサイズが1.5倍近くなるので、処理速度を検証後にかんがえる
  #sqlite3 $SQDB "create index my_index on my_mozc(src);"
done

shell内で使う、wash_dic.pl

#!/usr/bin/perl -w

# 事前作業として、
# 一行目のperlのPATHの変更と、Unicode::Japanese のインストール
# あと、このスクリプトの実行権限付与(chmod)をお忘れなく
#
# 実行すると "cost over:" の警告が出るはずです。
# メジャーな姓名の場合は(同音で別字の)総数が99個を超えるので
# これらは優先順位づけを諦める(99固定)という処理をやっています。
# それがどの程度の頻度か、動かしてみてご確認下さい。
#
#---------------------------------------
# ソート済みの変換表一覧 通番,かな,漢字 データを洗う
# - 通番を振り直す. int-1byte(<128) に収める
# - かな=漢字 のデータは SI/SO に置き換える(chr14, 15)
#   SI/SOは実際にDBを読み込む処理に合わせるので
#   任意の一文字なら何でもOKです(+/- でも #/$ でも何でも)
#
# 特にカタカナ判定は、shellだと荷が重いのでperlスクリプトにしました

#--- 以下はデバッグログ関数 ----------------
sub dbg_printf {
  printf STDOUT @_ ;
  $|=1;
}
sub err {
  printf STDOUT @_ ;
  $|=1;
}
#-------------------------------------------
# カタカナ変換は tr を使うと、どうも安定しない(指定が難しい)ので
# 別途 Unicode::Japanese; をインストールした
# それでも..."ヴ" だけは不安なので(環境依存?)、別で再変換をかける。
# use utf8;
# binmode STDOUT, ':utf8';
#    $hira2kana =~ tr/ーぁ-んゔ/ーァ-ンヴ/;
use Unicode::Japanese;

my $in_file = "$ARGV[0]";
my $out_file = "dictionary_washed.tsv";
if($in_file =~ /([^\.]+)\.(tsv|txt)/){
    $out_file = sprintf("%s.washed", $1);
    dbg_printf("output:$out_file\n");
}else{
    die "non txt or tsv[$in_file]";
}

open($IN, "< $in_file") or die "$! [$in_file]\n";
open($OUT, "> $out_file") or die "$! [$out_file]\n";

my $log_line = 0;
my $log_si_count = 0;
my $log_so_count = 0;
my $cost;
my $last_kana = "__NO_DATA__";
while(<$IN>){
  $line = $_;
  $log_line++;
 
  my ($kana, $number, $kanji);
  if($line =~ /^([\d]+)\t([^\t]+)\t([^\t\n]+)$/){
    # 33  ゔぁーじにあ    ヴァージニア
    # 34  ゔぁーじにあしゅう      ヴァージニア州
    $number=$1;
    $kana=$2;
    $kanji=$3;

  }else{
    err("unexpected line:%s\n", $_);
    next;
  }

  if($kana eq $last_kana){
    $cost++;
  }else{
    $cost=1;
    $last_kana = $kana;
  }

  if($cost > 99){
    # 1byte(127) 以内に収めるのを目標にする
    err("cost over: %s\n", $_);
    $cost=99;
  }

  if($kana eq $kanji){
    $kanji = chr(15); # SI shift-in
    $log_si_count++;
  }elsif(length($kana) == length($kanji)){
    my $hira2kana = Unicode::Japanese->new($kana)->hira2kata->get;
    $hira2kana =~ s/ゔ/ヴ/g;
    if($kanji eq $hira2kana){
      #dbg_printf("wash: %s\t%s\t%s\n", $cost, $kana, $kanji); 
      $kanji = chr(14); # SO shift-out
      $log_so_count++;
    }
  }
  printf($OUT "%s\t%s\t%s\n", $cost, $kana, $kanji); 

}
close($OUT);

dbg_printf("total:%d, wash1:%d, wash2:%s\n", $log_line, $log_si_count, 
$log_so_count);

あとがき

似たようなことに挑戦する人の参考になればと思います。
sortの方法や、awkでのデータの抜き出し方、とか。

わかりにくい処理として
平仮名・片仮名を範囲指定する際に
「あ-ん・ア-ン」の範囲では気づかなかったのですが、
小文字の「ぁ」、小さい「ヶ」、「ヴ」等を探す際にハイフン「-」を使った範囲指定では失敗する場合があります。
日本語を扱う前に、最初にWeb等で平仮名の"文字コード表"を探して、
全体像を一度見てみた方がイメージがしやすいと思います。"ゑ"とか覚えてますか?
あとは、
プログラミング言語によっては(perlもですが)
日本語がマルチバイト文字であることを踏まえた処理が必要になったりならなかったりします。
文字化けした際には、文字コードとか、マルチバイトとか、その辺も思い出してみて下さい。