日本語CTRLを1から学習する - 3

はじめに

今回は日本語文章生成のための前処理について調べ
SentencePieceを用いて青空文庫のデータに対して文章のトークン分割を学習した。
検証はgoogle colaboratoryで行った。

ダウンロード

青空文庫のデータは以下のページからダウンロードできる。
https://github.com/aozorabunko/aozorabunko.git

リポジトリのデータ構成などについては以下のページが参考となった。
https://www.softantenna.com/wp/tips/howto-git-clone-aozora/

コード - ダウンロード

# リポジトリから最新バージョンのみをclone
!git clone --depth 1 https://github.com/aozorabunko/aozorabunko.git
# zipを一旦textフォルダに移動
!mkdir text
!find aozorabunko/cards -name '*.zip' -exec cp {} text \;

前処理

ダウンロードしたデータには本文以外にも著者名などの情報を多く含む。
そのため学習に必要な本文以外の情報を前処理によって落とす。

  • 元データから本文のみを抜き出す
  • 空白でない行が5行以上続くものを本文とする
  • 1行が200文字以上のものも本文とする

コード - 抽出

import os
import io
import zipfile
import codecs
from tqdm.notebook import tqdm

in_dir = "text"
files = os.listdir(in_dir)
print("number of files : ", len(files))

encoding = "shift-jis"
rows = []
line_limit = 5 # 連続で5行以上空白でない部分を本文とする
line_length_limit = 200 #1行に文章が全部入っているもの対策
fail_list = []
for file in tqdm(files[:]):
  zip_file = os.path.join(in_dir, file)
  try:
    with zipfile.ZipFile(zip_file) as ziphndl:
      target_files = [f for f in ziphndl.namelist() if f.endswith(".txt")]
      for target_file in target_files:
        with ziphndl.open(target_file) as file_hndl:
          lines = []
          out_txts = []
          out_files = []
          line_exceeds = []
          non_zero = 0
          try:
            for line in io.TextIOWrapper(file_hndl, encoding):
              txt = line.strip()
              if len(txt) > 0:
                non_zero += 1
                out_files.append(txt)
              else:
                non_zero = 0
                if len(out_files)>0:
                  if len(out_files) >= line_limit:
                    out_txts.extend(out_files)
                  out_files = []
              if len(txt) > line_length_limit:
                line_exceeds.append(txt)            
              lines.append(txt)
            if len(line_exceeds) > 0:
              out_txts.extend(line_exceeds)

            title = lines[0]
            author = ""
            for line in lines[1:5]:
              if len(line) > 0:
                author = line
                break

            rows.append((zip_file, target_file, title, author, "\n".join(out_txts)))
          except:
            fail_list.append((zip_file, target_file))      
  except:
    fail_list.append(zip_file)

rows_df = pd.DataFrame(rows, columns=["zipfile", "filename", "title", "author", "text"])
print(rows_df.shape)
rows_df.head()

解析

コード - 解析

rows_df["text_len"] = rows_df["text"].map(len)
rows_df["line_count"] = rows_df["text"].map(lambda x:len(x.split("\n")))

stats = []
stats.append(["文章数", len(rows_df)])
stats.append(["平均文字数", rows_df["text_len"].mean()])
stats.append(["最大文字数", rows_df["text_len"].max()])
stats.append(["0文字文章数", (rows_df["text_len"]==0).sum()])
stats.append(["平均改行数", rows_df["line_count"].mean()])
stats.append(["最大改行数", rows_df["line_count"].max()])
print(pd.DataFrame(stats, columns=["統計量", "値"]).set_index("統計量").to_markdown())

統計量
文章数 16030
平均文字数 21017.8
最大文字数 1.30674e+06
0文字文章数 648
平均改行数 187.612
最大改行数 17515

f:id:nakamrnk:20200413083502p:plain

1つの文章が平均20000文字、187行ほどある。
CTRLは元の実装では1つの入力あたりのトークン数が256であるのでこのままでは長すぎる。
文章をいくつかに分割して入力する必要がある。

0文字の文章があるがこれは文章の抽出方法が完璧でないから生じている。
現状の抽出方法では文章と文章の間に空白が入っているようなものを
無視してしまうが、数はそこまで多くないのでこのまま利用する。   

SentencePiece

SentencePieceは文章の効率の良いトークン化を行うためのライブラリである。

理論論文:
https://arxiv.org/abs/1804.10959
実装論文:
https://arxiv.org/abs/1808.06226
解説:
https://qiita.com/taku910/items/7e52f1e58d0ea6e7859c

SentencePieceを使うことにより文章データから文章のトークン化を自動で学習してくれる。
日本語の自然言語処理では形態素解析により文章を単語単位に分割する前処理がよく行われるが
SentencePieceを使うとその処理が必要なくなる。
分割アルゴリズムの内容はよく理解できていないが、vocabulary数を決め、 与えられた文章データに対して
uni-gramの言語モデルを最適化するように分割を学習しているらしい。

解説記事:
https://qiita.com/takeshikondo/items/c579db96e8e60d1608da

学習

青空文庫の文章に対して sentence pieceを学習してみた。
語彙数は5000を指定。

コード - SentencePiece学習

test_count = 16000
# Sentence Pieceで学習するために一旦テキストファイルに文章を書き出す。
test_file = "test.txt"
with open(test_file, "w") as hndl:
 hndl.writelines(list(rows_df["text"].sample(test_count)))

# vocabulary数 5000で学習
print("start training")
t0 = time.time()
spm.SentencePieceTrainer.train('--input={} --model_prefix={} --vocab_size=5000'.format(test_file, "test_model_{:06d}".format(test_count)))
t1 = time.time()
dt = t1 - t0
print(test_count, " : cpu time  ", dt)

16000文に対して学習時間はColabo上で2時間程度だった。

コード - 推論

model_file = "test_model_{:06d}.model".format(test_count)
sp_test = spm.SentencePieceProcessor()
sp_test.load(model_file)

target_texts = [
    "今日はいい天気だ",
    "もしも明日がきたならば、おいしいカレーを食べよう",
    "私が幼いころ通っていた小学校は海の上に浮かんでいた"
]
res = []
for target_text in target_texts:
  res.append((target_text, sp_test.encode_as_pieces(target_text)))

res_df = pd.DataFrame(res, columns=["元文章", "分割文章"]).set_index("元文章")
print(res_df.to_markdown())

元文章 分割文章
今日はいい天気だ ['▁', '今日', 'は', 'いい', '天', '気', 'だ']
もしも明日がきたならば、おいしいカレーを食べよう ['▁', 'もし', 'も', '明', '日', 'が', 'きた', 'ならば', '、', 'お', 'い', 'しい', 'カ', 'レ', 'ー', 'を', '食', 'べ', 'よう']
私が幼いころ通っていた小学校は海の上に浮かんでいた ['▁', '私が', '幼', 'い', 'ころ', '通', 'っていた', '小', '学校', 'は', '海', 'の上に', '浮', 'か', 'んで', 'いた']

頻出する表現"いい", "もし"などは1つのトークンとして扱われている。
また、小説によく使われていると思われる"今日", "学校"などの表現も1トークンとなっている。
やや文字単位の分割が多く感じる。
青空文庫の小説は違う時代の文章が含まれているため、共通した表現が少なく
今回指定した語彙数5000はやや少なかったかもしれない。

語彙数15000で再学習した結果を以下に記載する。

元文章 分割文章
今日はいい天気だ ['▁', '今日は', 'いい', '天気', 'だ']
もしも明日がきたならば、おいしいカレーを食べよう ['▁', 'もしも', '明日', 'が', 'きた', 'ならば', '、', 'おい', 'しい', 'カ', 'レー', 'を', '食べ', 'よう']
私が幼いころ通っていた小学校は海の上に浮かんでいた ['▁', '私が', '幼い', 'ころ', '通', 'っていた', '小学校', 'は', '海', 'の上に', '浮', 'か', 'んでいた']

語彙数5000の場合と比較すると1つのトークンあたりの平均文字数は増えている。
普通の形態素解析で分割した結果に近くなっている印象。

適切な語彙数やどのデータセットからどれくらいの割合で学習するかなどは
今後他のデータセットとの関係も考慮して調整する必要があると思われる。

まとめと今後

今回はSentencePieceを用いて青空文庫データからトークナイザを学習した。
今後は他のデータを集めて学習用のスクリプトを作りたい。

参考文献