StatsBeginner: 初学者の統計学習ノート

統計学およびR、Pythonでのプログラミングの勉強の過程をメモっていくノート。たまにMacの話題。

小型のTransformerに英日翻訳をイチから学習させてみた

翻訳機を自作してみた

ChatGPTやDeepLの元になったTransformer*1をつかって、英語から日本語への翻訳を学習させてみました。
とりあえず現段階で、いくつかの英文を翻訳させてみた結果が以下のとおりです(青字が機械翻訳)。

Your time is limited, so don’t waste it living someone else’s life.
時間は限られているので、他人の人生を浪費しないでください。
(拙訳:君たちの時間は限られている。他人の人生を生きるようなことをしてそれを無駄にするな。/スティーブ・ジョブズ)
 
I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character.
私は4人の小さな子供たちが、いつか、自分の肌の色ではなく、その性格の特徴によって判断されない国家に住む夢を持っています。
(拙訳:私には夢がある。私の4人の子供たちがいつの日か、肌の色ではなく彼らの人格によって判断される国に暮らせるようになるという夢が。/キング牧師)
 
You are fake news!
偽のニュースです!
(拙訳:[CNNは]フェイクニュースだ!/トランプ大統領)
 
You may say I'm a dreamer. But I'm not the only one. I hope someday you'll join us. And the world will be as one.
夢想家だって言うかもしれないけど、私だけじゃない。いつか、ぼくらと合流してくれるといいんだけど。そして世界は、ひとつのものになる。
(拙訳:夢想家だと君は言うかもしれないけれど、僕は一人じゃない。いつか君も一緒になれれば。そして世界は一つになる。/ジョン・レノン)
 
The madman is not the man who has lost his reason. The madman is the man who has lost everything except his reason.
狂人は理性を失った人間ではない。狂人は、理性以外はすべてを失った男だ。
(安西徹雄訳:狂人とは理性を失った人のことではない。狂人とは、理性以外のあらゆる物を失った人のことである。/チェスタトン)
 
The safest general characterization of the European philosophical tradition is that it consists of a series of footnotes to Plato.
ヨーロッパ哲学伝統の最も安全な一般的な特徴は、それがプラトンへの一連の脚注から構成されていることです。
(拙訳:ヨーロッパ哲学の伝統について間違いなく言えるのは、その全てが、プラトン哲学へのひと続きの注釈に過ぎないということである。/ホワイトヘッド)
 
Violence sometimes may have cleared away obstructions quickly, but it never has proved itself creative.
暴力は時々すぐに妨害を片付けるかもしれませんが、それは創造的に証明されたことはありません。
(拙訳:暴力が、問題を手っ取り早く片付けるのに役立つことはある。しかし、暴力それ自身が創造的であったことは一度もない。/アインシュタイン)


表現がこなれてなかったり正しく訳せてなかったりするのですが、ひと昔前のGoogle翻訳ぐらいの性能は出ている気がしますね。個人で、小規模なコーパスを半日ぐらい学習させただけでも、これぐらい訳せるのだから、やはりTransformerは偉大ですね。ちなみに、手元のモデル自体もまだ改善の余地があり、コーパスも大きくしようと思えばできます。
 
  

学習の概要

どういう学習をやったか簡単にメモしておくと、以下のような感じです。

  • 学習データについては、無償配布されている英日対訳のコーパスを何種類か集めて、クリーニングした上で統合したものを使いました(先日のエントリで手順を説明)。対訳文の数は、トータルで約128万ペアです。
  • モデルはAll You Need論文の基本構成に近いものですが、Transformerブロックはエンコーダ側・デコーダ側ともに3層ずつなので浅めです(All You Need論文は各6層)。フィードフォワード部分の隠れ層の次元も512と小さめです(All You Need論文は2048)。パラメータ数は約5990万個です(All You Need論文の構成だと約1.1億個になる)。
  • PyTorchのTransformerクラスを使っており、コードもPyTorchのチュートリアルに従いつつ、何点か修正を加えて動かしています。
  • Google Colab上で、A100というGPUのランタイムを使って学習させました。
  • 検証誤差はまだ低下中なので、まだ学習を進める余地があるのですが、とりあえず30エポックまで学習させて16時間ぐらいかかりました。


コードを書く上で、基本的にはPyTorchの以下のチュートリアルを参考にしましたが、次に述べるような変更を行っています。*2
Language Translation with nn.Transformer and torchtext — PyTorch Tutorials 2.0.1+cu117 documentation

  • コーパスは別のところから取ってきて、独自に整理したものを使った。
  • トークナイザはSentencePieceを使ってByte-pair Encodingを行って作成した。
  • 変数名がわかりにくかったり、記述の順番がわかりにくかったところは、適宜整理した(特にトークナイザ周辺)。
  • 学習時に勾配累積を行えるようにした。
  • 学習済みのモデルをロードして推論や追加学習を行うために、色々保存しておかないといけないものがあるので、整理して保存する部分を追加した。
  • 貪欲法だけでなくビームサーチでの推論もやってみた。


で、まだ改善の余地がありまして、具体的には以下のようなことが考えられます。

  • まず、30エポックで止めてますが、まだ検証誤差は減少中だったので、学習を継続すればもう少し精度が上がるかと思います。(⇒大して上がりませんでしたw)
  • コーパスは、全体としては3000万ぐらいのペアがある中から選別して128万件を抽出しているので、拡大することはできます。2500万と最大のデータ量をもつJParaCrawlというコーパスが、よくみると品質がいまいちなので、単純に全部使えばいいわけではなく精査が必要ですが*3、数百万ぐらいの規模にはできる気がします。
  • モデルをもっと大規模にする(トランスフォーマーブロックのスタックを増やす、フィードフォワード部分や埋め込みの次元数を増やす等)こともできます。コーパスを増やさずにモデル規模だけでどれだけ性能が上がるかは、やってみないとわかりませんが、AIl You Need論文のBase構成ぐらいまでは大きくしてもいいのかも知れない。
  • 単なる規模以外に、各種ハイパーパラメータの組み合わせも色々試して比較したわけではないので、工夫の余地があるかもです。
  • ビームサーチの精度が低いので、何か間違えてるかも知れません。冒頭で紹介した翻訳結果の例はすべて、貪欲法でやったものです。ビームサーチすると、近い内容ではあるものの、最後に。。。が繰り返されたりして変になります。
  • 大変そうなので今のところやる予定はないものの、たとえば日本語と英語のWikipediaをそれぞれLLMとして事前学習させた上で、ファインチューニングで翻訳を学習させると、精度は上がるんじゃないでしょうか。Wikipediaだったらデータを手に入れるのが簡単だし。ただし計算時間はえらいことになりそうで、私はできれば学習を自分を試したいのですが、Hugging Faceにある事前学習済みモデルを使えばいいかも知れません。
  • 翻訳の性能とは関係ないが、学習速度を早めるために、シーケンスをパディングするのをやめたほうがいいかもしれない。(今はバッチ内で最長のシーケンスに合わせてパディングしているので、バッチ内に長文が混ざると一気にテンソルがでかくなってメモリを圧迫している。)

 
 

学習に使ったコード

以下にコードを掲載しておきます。
モジュールをどこで使うのかがわかりやすいように、一番上にまとめてではなくセクションごとにインポートしてます。
セクション名の後に(☆)がついているのは、学習済みモデルを読み込んで推論を行う際に改めて実行する部分です。途中まで学習したモデルを読み込んで追加学習するときも、だいたい同じですが、当然学習に係る部分に要否は異なります。
いろいろ直しながら書いていったコードそのまんまなので、あまり整理されておらず、もっとラップしたりして見やすくしたほうがいいとは思います。
 

ランタイム立ち上げ、モジュールインポート(☆)

# SentencePieceのインストール
! pip install sentencepiece

# Googleドライブのマウント
from google.colab import drive
drive.mount('/content/drive')

# 作業ディレクトリ変更
import os
import sys
os.chdir('作業ディレクトリのパスを入れる')

# Colab側の保存フォルダをつくる
# Google Driveとの接続に不具合があったときを考えてColab側にもデータを一時保存するため
! mkdir /content/save_temp

 

ディレクトリの決定、パスの設定(☆)

これは完全に私の個人的な都合で設定しているものなので、どうでもいいっちゃどうでもいい。
コーパスとかモデルごとに「プロジェクト」としてまとめようと思ったので、そういうディレクトリ構成になっています。
学習済みもしくは途中まで学習したモデルを読み込んで、追加学習を行ったり推論を行ったりすることがあるわけですが、そのためには関連ファイルをきちんとロードする必要があって、最初は適当にやってたので何度かミスりました。それを避けるために、やや細かくディレクトリを整理しました。

import os

# プロジェクト名の設定
pj_name = 'EJTrans_1.3M'  # ★ここ設定

# プロジェクト自体のパス
pj_dir_path = 'projects/' + pj_name

# サブディレクトリのパス
corpus_dir_path   = pj_dir_path + '/corpus' # コーパス置き場
SP_dir_path       = pj_dir_path + '/SP'     # SentencePieceのモデル置き場
vocab_dir_path    = pj_dir_path + '/vocab'  # ボキャブラリの保存場所
arch_dir_path     = pj_dir_path + '/arch'   # モデルアーキテクチャの保存場所
model_dir_path    = pj_dir_path + '/model'  # 学習済みモデルのパラメータの保存場所

# ディレクトリの作成(なければ作る)
def check_and_make_dir(path):
  if not os.path.exists(path):
    os.makedirs(path)
check_and_make_dir(pj_dir_path)     # プロジェクト自体のディレクトリ
check_and_make_dir(corpus_dir_path) # コーパス置き場(英日別と、SP学習用の連結コーパス)
check_and_make_dir(SP_dir_path)     # SentencePieceのモデル置き場
check_and_make_dir(vocab_dir_path)  # ボキャブラリの保存場所
check_and_make_dir(arch_dir_path)   # モデルアーキテクチャの保存場所
check_and_make_dir(model_dir_path)  # 学習済みモデルの保存場所

# プロジェクトのログファイルを作成
import datetime
log_filename = f"{pj_dir_path}/training_log.txt"
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(log_filename, "w") as f:
    first_line = f"Training Log: created at {current_time}"
    f.write(first_line + "\n")

 

コーパスの事前処理

これもどういうコーパスを使うかによってかわりますが、私は先日のエントリで作成したような、「日本語と英語で1ファイルずつになっていて、行ごとに対訳として対応している」というコーパスファイルを作ってあったので、それを使う前提になっています。
ここでの処理としては、

  • 訓練データ、検証データ、テストデータに分割する(結果的に、テストは「適当に英文を入れてみて目視で性能をみる」ことしかやってないので、テストデータは使ってないですがw)
  • SentencePieceでバイトペアエンコーディングの学習をする際、All You Need論文にあわせて各言語共通の語彙空間をつくるようにしたので、英語と日本語を(SentencePieceの学習のためだけに)連結したものも作りました。
import shutil

'''リストの1要素を1行ずつテキストファイルに書き込む関数'''
def save_lines(text_list, path):
  with open(path, "w") as file:
      for l in text_list:
          _ = file.write(l)


'''オリジナルコーパスの取得元'''  # ★ここ設定
orig_corpus_en_path = 'data/en_ja_translation/mixed_1.3M_en.txt'
orig_corpus_ja_path = 'data/en_ja_translation/mixed_1.3M_ja.txt'


'''作成するコーパスのパスを予め指定'''
corpus_en_path = corpus_dir_path + '/corpus_en.txt'
corpus_ja_path = corpus_dir_path + '/corpus_ja.txt'


'''コーパスを元データ置き場から本プロジェクトのディレクトリにコピー配置'''
shutil.copyfile(orig_corpus_en_path, corpus_en_path)  # すでにあれば上書きする
shutil.copyfile(orig_corpus_ja_path, corpus_ja_path)  # すでにあれば上書きする


'''上記コーパスの読み込み'''
with open(corpus_en_path) as f:
    corpus_en = f.readlines()
with open(corpus_ja_path) as f:
    corpus_ja = f.readlines()


'''コーパスのサイズを限定する処理(デフォルトは全部利用)'''
full_corpus_len = len(corpus_en)
corpus_size = full_corpus_len  # サイズを限定する時は数値、全部使うならfull_corpus_len  ★ここ設定
if corpus_size < full_corpus_len:
  corpus_en = corpus_en[:corpus_size]
  corpus_ja = corpus_ja[:corpus_size]

# 後でトークナイザでロードするので(数を限定した場合は)改めて保存
if corpus_size < full_corpus_len:
  save_lines(corpus_en, corpus_en_path)
  save_lines(corpus_ja, corpus_ja_path)


'''コーパスを訓練用・検証用・テスト用に分割'''

# 分ける比率
split_ratio = [0.9, 0.05, 0.05]  # ★ここ設定

# 関数定義
def split_corpus(corpus, split_ratio, role, save_path):
  end_train = round(len(corpus)*split_ratio[0])
  end_valid = end_train + round(len(corpus)*split_ratio[1])
  if role=='train':
    save_lines(corpus[:end_train], save_path)
  if role=='valid':
    save_lines(corpus[end_train:end_valid], save_path)
  if role=='test':
    save_lines(corpus[end_valid:], save_path)

split_corpus(corpus_en, split_ratio, 'train', corpus_dir_path + '/corpus_en_train.txt')
split_corpus(corpus_en, split_ratio, 'valid', corpus_dir_path + '/corpus_en_valid.txt')
split_corpus(corpus_en, split_ratio, 'test',  corpus_dir_path + '/corpus_en_test.txt')
split_corpus(corpus_ja, split_ratio, 'train', corpus_dir_path + '/corpus_ja_train.txt')
split_corpus(corpus_ja, split_ratio, 'valid', corpus_dir_path + '/corpus_ja_valid.txt')
split_corpus(corpus_ja, split_ratio, 'test',  corpus_dir_path + '/corpus_ja_test.txt')

'''訓練コーパスの英語・日本語を連結したものを作成(SentencePiece用)'''
with open(corpus_dir_path + '/corpus_en_train.txt') as f:
    corpus_en_train = f.readlines()
with open(corpus_dir_path + '/corpus_ja_train.txt') as f:
    corpus_ja_train = f.readlines()
save_lines(corpus_en_train + corpus_ja_train, corpus_dir_path + '/corpus_enja_train.txt')

 

トークナイザの構築

トークナイザのセクションで必要な作業は、

  • BPEによって文章をサブワード(文字と単語の間ぐらいの中途半端な単位)に分割する
  • サブワードにIDをふって語彙空間を生成する
  • 開始記号、終了記号、未知語記号、パディング記号を定義して語彙空間にセットする

です。
なお、SentencePieceの学習にはけっこう時間がかかる(今回のデータ量で数十分)ので、1回構築したら、Transformerの学習中/済みモデルを読み込んで追加学習や推論を行う際は、SentencePieceのモデルも単に読み込むだけにしたほうがいいです。
チュートリアルの変数名は意味不明だったので変更しています。それ以外にもいろいろいじった気がする。

### SentencePieceの学習(コーパスがでかいとけっこう時間かかる)

import sentencepiece as spm

corppus_enja_path = corpus_dir_path + '/corpus_enja_train.txt'
sp_model_path = SP_dir_path + '/sp'
vocab_size = '30000'  # ★ここ設定

sp_command = '--input=' + corppus_enja_path + ' --model_prefix=' + sp_model_path + ' --vocab_size=' + vocab_size + ' --character_coverage=0.995 --model_type=bpe'
spm.SentencePieceTrainer.train(sp_command)


### トークナイザの構築

import sentencepiece as spm
import torch
from torchtext.vocab import build_vocab_from_iterator

'''SentencePieceの学習済みデータのロード'''
sp = spm.SentencePieceProcessor()
sp.load(SP_dir_path + '/sp.model')


'''src側とtgt側の言語の設定'''
SRC_LANGUAGE = 'en'
TGT_LANGUAGE = 'ja'


'''文字列をサブワードに分割する“text_to_subwords”の構築'''
# token_transformから名前を変更!!!

# サブワード分割器の生成器(所与のspモデルに基づく)
# こちらは与えられた「文字列」をサブワードに分けるもの
def sp_splitter(sp_model):
    def get_subwords(text):
        return sp_model.encode_as_pieces(text)
    return get_subwords

# 両言語のサブワード分割器を格納(今回は英日共通だが)し、言語名で取り出せるように
text_to_subwords = {}
text_to_subwords[SRC_LANGUAGE] = sp_splitter(sp)
text_to_subwords[TGT_LANGUAGE] = sp_splitter(sp)


'''サブワードをID化する“subwords_to_ids”の構築'''
# vocab_transformから名前を変更!!!

# SentencePieceの"encode_as_ids"でも作れるが、特殊記号のIDを明示的に
# 指定するために、build_vocab_from_iteratorを使っている

# ファイル内のテキストをサブワードに分割したものをイテレータとして返す関数
def file_to_subwords(data_path):
    with open(data_path, 'r', encoding='utf-8') as f:
        for line in f:
            yield sp.encode_as_pieces(line)

# 特殊トークンの定義(未知語、パディング、開始、終了)
UNK_IDX, PAD_IDX, BOS_IDX, EOS_IDX = 0, 1, 2, 3
special_symbols = ['<unk>', '<pad>', '<bos>', '<eos>']

# サブワードをIDに変換する変換器を両言語分格納
# これは語彙辞書としても機能する
subwords_to_ids = {}
subwords_to_ids[SRC_LANGUAGE] = build_vocab_from_iterator(file_to_subwords(corpus_dir_path + '/corpus_en_train.txt'), min_freq=1, specials=special_symbols, special_first=True)
subwords_to_ids[TGT_LANGUAGE] = build_vocab_from_iterator(file_to_subwords(corpus_dir_path + '/corpus_ja_train.txt'), min_freq=1, specials=special_symbols, special_first=True)

# デフォルトを未知語トークンに指定(この指定は必須)
for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
  subwords_to_ids[ln].set_default_index(UNK_IDX)

'''ボキャブラリの保存'''
torch.save(subwords_to_ids[SRC_LANGUAGE], vocab_dir_path + '/vocab_en.pth')
torch.save(subwords_to_ids[TGT_LANGUAGE], vocab_dir_path + '/vocab_ja.pth')

 

トークナイザのロード(☆)

これは、学習中/済みモデルをロードして、学習を再開したり推論を行ったりする場合に使用する部分です。最初の学習時は実行しません(しても問題ないけど)。

# トークナイザのロード

import sentencepiece as spm
import torch

SRC_LANGUAGE = 'en'
TGT_LANGUAGE = 'ja'

'''サブワード分割器のロード'''
sp = spm.SentencePieceProcessor()
sp.load(SP_dir_path + '/sp.model')
def sp_splitter(sp_model):
    def get_subwords(text):
        return sp_model.encode_as_pieces(text)
    return get_subwords
text_to_subwords = {}
text_to_subwords[SRC_LANGUAGE] = sp_splitter(sp)
text_to_subwords[TGT_LANGUAGE] = sp_splitter(sp)

'''ID変換器(ボキャブラリ)のロード'''
subwords_to_ids = {}
subwords_to_ids[SRC_LANGUAGE] = torch.load(vocab_dir_path + '/vocab_en.pth')
subwords_to_ids[TGT_LANGUAGE] = torch.load(vocab_dir_path + '/vocab_ja.pth')

 

テキスト→テンソル変換器(☆)

テキストを、モデルに入れられる形のテンソルに変換するための部品です。チュートリアルではもっと下のほうに書いてあったのですが、分かりにくいので上にもってきています。

### テキストをTransformerに突っ込めるテンソルに変換する手続き
# チュートリアルではtext_transformと名付けられているもの

from torch.nn.utils.rnn import pad_sequence
from typing import List

# 与えられたトークンIDリストの先頭と末尾にそれぞれBOSとEOSのトークンIDを追加
BOS_IDX = subwords_to_ids[SRC_LANGUAGE]['<bos>']
EOS_IDX = subwords_to_ids[SRC_LANGUAGE]['<eos>']
def add_bos_eos(token_ids: List[int]):
    return torch.cat((torch.tensor([BOS_IDX]),
                      torch.tensor(token_ids),
                      torch.tensor([EOS_IDX])))

# テキストからテンソルまでの流れを定義
def sequential_transforms_src(input):
  tok_out = text_to_subwords[SRC_LANGUAGE](input)
  voc_out = subwords_to_ids[SRC_LANGUAGE](tok_out)
  ten_out = add_bos_eos(voc_out)
  return ten_out
def sequential_transforms_tgt(input):
  tok_out = text_to_subwords[TGT_LANGUAGE](input)
  voc_out = subwords_to_ids[TGT_LANGUAGE](tok_out)
  ten_out = add_bos_eos(voc_out)
  return ten_out

# テキストをtransformerに突っ込めるテンソルにする
text_to_tensor = {}
text_to_tensor[SRC_LANGUAGE] = sequential_transforms_src
text_to_tensor[TGT_LANGUAGE] = sequential_transforms_tgt


# 中身を一応確認する場合
text_to_tensor['en']('apple')

 

Transformerモデルの設計(☆)

 ここがモデルのコア部分です。

# Transformer構築に使うモジュール
from torch import Tensor
import torch
import torch.nn as nn
from torch.nn import Transformer
import math


'''ポジショナルエンコーディング(チュートリアル通り)'''

class PositionalEncoding(nn.Module):
    def __init__(self,
                 emb_size: int,
                 dropout: float,
                 maxlen: int = 5000):
        super(PositionalEncoding, self).__init__()
        den = torch.exp(- torch.arange(0, emb_size, 2)* math.log(10000) / emb_size)
        pos = torch.arange(0, maxlen).reshape(maxlen, 1)
        pos_embedding = torch.zeros((maxlen, emb_size))
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        pos_embedding = pos_embedding.unsqueeze(-2)

        self.dropout = nn.Dropout(dropout)
        self.register_buffer('pos_embedding', pos_embedding)

    def forward(self, token_embedding: Tensor):
        return self.dropout(token_embedding + self.pos_embedding[:token_embedding.size(0), :])


'''埋め込み(チュートリアル通り)'''

class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size):
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, emb_size)
        self.emb_size = emb_size

    def forward(self, tokens: Tensor):
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)


'''Transformer本体(チュートリアル通り)'''

class Seq2SeqTransformer(nn.Module):
    def __init__(self,
                 num_encoder_layers: int,
                 num_decoder_layers: int,
                 emb_size: int,
                 nhead: int,
                 src_vocab_size: int,
                 tgt_vocab_size: int,
                 dim_feedforward: int = 512,
                 dropout: float = 0.1):
        super(Seq2SeqTransformer, self).__init__()
        self.transformer = Transformer(d_model=emb_size,
                                       nhead=nhead,
                                       num_encoder_layers=num_encoder_layers,
                                       num_decoder_layers=num_decoder_layers,
                                       dim_feedforward=dim_feedforward,
                                       dropout=dropout)
        self.generator = nn.Linear(emb_size, tgt_vocab_size)
        self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
        self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
        self.positional_encoding = PositionalEncoding(
            emb_size, dropout=dropout)

    def forward(self,
                src: Tensor,
                trg: Tensor,
                src_mask: Tensor,
                tgt_mask: Tensor,
                src_padding_mask: Tensor,
                tgt_padding_mask: Tensor,
                memory_key_padding_mask: Tensor):
        src_emb = self.positional_encoding(self.src_tok_emb(src))
        tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
        outs = self.transformer(src_emb, tgt_emb, src_mask, tgt_mask, None,
                                src_padding_mask, tgt_padding_mask, memory_key_padding_mask)
        return self.generator(outs)

    def encode(self, src: Tensor, src_mask: Tensor):
        return self.transformer.encoder(self.positional_encoding(
                            self.src_tok_emb(src)), src_mask)

    def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
        return self.transformer.decoder(self.positional_encoding(
                          self.tgt_tok_emb(tgt)), memory,
                          tgt_mask)

'''マスクの生成(チュートリアル通り)'''

PAD_IDX = subwords_to_ids[SRC_LANGUAGE]['<pad>']

'''
正方行列の上三角部分に True、それ以外に False を持つ行列を生成する。
その後、Trueの要素を-infに、Falseの要素を0.0に置き換える。
'''
def generate_square_subsequent_mask(sz):
    mask = (torch.triu(torch.ones((sz, sz), device=DEVICE)) == 1).transpose(0, 1)
    mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
    return mask

def create_mask(src, tgt):
    src_seq_len = src.shape[0]
    tgt_seq_len = tgt.shape[0]

    tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
    src_mask = torch.zeros((src_seq_len, src_seq_len),device=DEVICE).type(torch.bool)

    src_padding_mask = (src == PAD_IDX).transpose(0, 1)
    tgt_padding_mask = (tgt == PAD_IDX).transpose(0, 1)
    return src_mask, tgt_mask, src_padding_mask, tgt_padding_mask

 

モデルのアーキテクチャ(Transformerのハイパーパラメータ)の設定

ここでアークテクチャと言っているのは、Transformerのモデルの各パーツのサイズみたいな感じです。
あとで呼び出すことがあるので、PyTorchのオブジェクトとして保存してます。

'''モデルアーキテクチャの設定と保存'''
# 学習時に使うドロップアウト率はclass Seq2SeqTransformerブロックの定義のところで固定してる

model_arch = {
    'NUM_ENCODER_LAYERS':3,                                # エンコーダ側のブロック数
    'NUM_DECODER_LAYERS':3,                                # デコーダ側のブロック数
    'EMB_SIZE':512,                                        # 埋め込み次元
    'NHEAD':8,                                             # マルチヘッドの数
    'SRC_VOCAB_SIZE':len(subwords_to_ids[SRC_LANGUAGE]),   # 英語の語彙次元
    'TGT_VOCAB_SIZE':len(subwords_to_ids[TGT_LANGUAGE]),   # 日本語の語彙次元
    'FFN_HID_DIM':512                                      # 順伝播層の次元
}
torch.save(model_arch, arch_dir_path + '/model_arch.pth')

 

学習の準備

Transformerモデルのアーキテクチャを設定してインスタンスを作成したり、オプティマイザのインスタンスを作成したりしています。学習率のスケジューリングを書くのが面倒だったので、固定にしてますが、エポックの進展にあわせて小さくしていくようにしたほうがいいとは思います。私は、途中まで学習して止めて、モデルをロードしてまた追加学習するということを繰り返していたので、手動で途中からlrを小さくしましたw

### デバイスの選択:GPUへの切り替え
# ifになってるが事実上、CPUで学習は不可能
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')


'''(A) 最初から学習する場合の準備'''

# モデルのインスタンス構築
transformer = Seq2SeqTransformer(num_encoder_layers = model_arch['NUM_ENCODER_LAYERS'],
                                 num_decoder_layers = model_arch['NUM_DECODER_LAYERS'],
                                 emb_size = model_arch['EMB_SIZE'],
                                 nhead = model_arch['NHEAD'],
                                 src_vocab_size = model_arch['SRC_VOCAB_SIZE'],
                                 tgt_vocab_size = model_arch['TGT_VOCAB_SIZE'],
                                 dim_feedforward = model_arch['FFN_HID_DIM'])

# モデル内の重み・バイアスを一様分布で初期化
torch.manual_seed(0)
for p in transformer.parameters():
    if p.dim() > 1:
        nn.init.xavier_uniform_(p)

# デバイスのセット
transformer = transformer.to(DEVICE)

# 誤差関数の定義(バッチ内で系列長をあわせるためのパディングトークンについては評価しない)
PAD_IDX = subwords_to_ids[SRC_LANGUAGE]['<pad>']
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# ネットワークの初期パラメータを渡し、学習率等も設定してオプティマイザを初期化  ★ここ設定する
optimizer = torch.optim.Adam(transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9)  # epsはゼロで割るのを回避するための部品


'''(B) 途中から学習を再開する場合の準備'''

# アーキテクチャ設定のロード
model_arch = torch.load(arch_dir_path + '/model_arch.pth')

# インスタンス立ち上げ
transformer = Seq2SeqTransformer(num_encoder_layers = model_arch['NUM_ENCODER_LAYERS'],
                                 num_decoder_layers = model_arch['NUM_DECODER_LAYERS'],
                                 emb_size = model_arch['EMB_SIZE'],
                                 nhead = model_arch['NHEAD'],
                                 src_vocab_size = model_arch['SRC_VOCAB_SIZE'],
                                 tgt_vocab_size = model_arch['TGT_VOCAB_SIZE'],
                                 dim_feedforward = model_arch['FFN_HID_DIM'])

# モデルにデバイスをセット
transformer.to(DEVICE)

# 学習済みパラメータをロード
transformer.load_state_dict(torch.load(model_dir_path + '/best_model_epoch=25_valloss=2.400.pth'))

# 誤差関数の定義(バッチ内で系列長をあわせるためのパディングトークンについては評価しない)
PAD_IDX = subwords_to_ids[SRC_LANGUAGE]['<pad>']
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# ネットワークの初期パラメータを渡し、学習率等も設定してオプティマイザを初期化  ★ここ設定する (学習再開時にlrを下げたりしてる)
optimizer = torch.optim.Adam(transformer.parameters(), lr=0.00001, betas=(0.9, 0.98), eps=1e-9)  # epsはゼロで割るのを回避するための部品

 

データローダのセット

学習時に、コーパスの中からデータを取って、バッチにまとめてモデルに供給していく装置です。

### データローダの構築
# 学習ルーチンにデータを供給するローダ

from torch.utils.data import Dataset
from torch.utils.data import DataLoader

# バッチサイズ  ★ここ設定する
BATCH_SIZE = 128

# コーパスファイルを読み込んで1件ずつ与えていく動きの関数
class CustomDataset(Dataset):
    def __init__(self, src_path, tgt_path):
        with open(src_path, 'r', encoding='utf-8') as f:
            self.src_data = [line.strip() for line in f]
        with open(tgt_path, 'r', encoding='utf-8') as f:
            self.tgt_data = [line.strip() for line in f]

    def __len__(self):
        return len(self.src_data)

    def __getitem__(self, idx):
        return self.src_data[idx], self.tgt_data[idx]

# テキストをテンソルに変換しパディングする関数
PAD_IDX = subwords_to_ids[SRC_LANGUAGE]['<pad>']

def collate_fn(batch):
    src_batch, tgt_batch = [], []
    for src_sample, tgt_sample in batch:
        src_batch.append(text_to_tensor[SRC_LANGUAGE](src_sample.rstrip("\n")))
        tgt_batch.append(text_to_tensor[TGT_LANGUAGE](tgt_sample.rstrip("\n")))
    src_batch = pad_sequence(src_batch, padding_value=PAD_IDX)
    tgt_batch = pad_sequence(tgt_batch, padding_value=PAD_IDX)
    return src_batch, tgt_batch

# 訓練用のローダを構築
train_dataset = CustomDataset(corpus_dir_path + '/corpus_en_train.txt', corpus_dir_path + '/corpus_ja_train.txt')
train_dataloader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)

# 検証用のローダを構築
val_dataset = CustomDataset(corpus_dir_path + '/corpus_en_valid.txt', corpus_dir_path + '/corpus_ja_valid.txt')
val_dataloader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)

 

学習ルーチン

学習と検証のルーチンです。勾配累積を使うようにチュートリアルから変更してます。

### 学習ルーチン

from torch.utils.data import DataLoader

# 勾配累積の回数を設定
GRADIENT_ACCUMULATION_STEPS = 2

'''新しい訓練ルーチン'''
# 勾配累積を行うように変更した

def train_epoch(model, optimizer, train_dataloader):
    model.train()
    losses = 0

    for step, (src, tgt) in enumerate(train_dataloader):
        src = src.to(DEVICE)
        tgt = tgt.to(DEVICE)

        tgt_input = tgt[:-1, :]

        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        logits = model(src, tgt_input, src_mask, tgt_mask, src_padding_mask, tgt_padding_mask, src_padding_mask)

        tgt_out = tgt[1:, :]
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        loss = loss / GRADIENT_ACCUMULATION_STEPS  # 勾配累積のためのスケーリング
        loss.backward()
        losses += loss.item()

        # 指定したステップ数で勾配を累積した後、パラメータを更新
        if (step + 1) % GRADIENT_ACCUMULATION_STEPS == 0:
            optimizer.step()
            optimizer.zero_grad()

    return losses / len(list(train_dataloader))


'''検証ルーチン'''
def evaluate(model, val_dataloader):
    model.eval()
    losses = 0

    for src, tgt in val_dataloader:
        src = src.to(DEVICE)
        tgt = tgt.to(DEVICE)

        tgt_input = tgt[:-1, :]

        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        logits = model(src, tgt_input, src_mask, tgt_mask,src_padding_mask, tgt_padding_mask, src_padding_mask)

        tgt_out = tgt[1:, :]
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        losses += loss.item()

    return losses / len(list(val_dataloader))

 

学習の実施

以下で学習を進めるのですが、要するに
train_loss = train_epoch(transformer, optimizer, train_dataloader)
の部分でローダからデータがモデルに供給されて、勾配が計算されたりパラメータが更新されたりしていきます。
検証ロスがベストを更新したら、モデルを保存するようにしています。その際、Google Colabの不具合でGoogle Driveとの接続がおかしくなることがあるので、Colabサーバ側でもモデルを保存するようにしています。これは緊急時にここからモデルを救うというものなので、別に保存してなくてもいいといえばいいです。
あと、ログを書き込むようにしてます。

'''学習の実施'''
from timeit import default_timer as timer

NUM_EPOCHS = 35  # ★ここ設定する

# 初期の最低検証ロスを無限大として設定
best_val_loss = float('inf')
#best_val_loss = 2.355  # 学習再開時は ★ここ設定する

for epoch in range(1, NUM_EPOCHS+1):  # 学習再開時でエポックのカウントを続きからにしたい場合は ★ここ設定する (通常は1から)
    torch.cuda.empty_cache()
    start_time = timer()
    train_loss = train_epoch(transformer, optimizer, train_dataloader)
    end_time = timer()
    val_loss = evaluate(transformer, val_dataloader)
    log_message = (f"Epoch: {epoch}, Train loss: {train_loss:.3f}, Val loss: {val_loss:.3f}, "f"Epoch time = {(end_time - start_time):.3f}s")
    print(log_message)
    with open(log_filename, "a") as f:
      f.write(log_message + "\n")

    # 検証ロスが最低を更新したらモデルを保存
    if val_loss < best_val_loss:
        best_val_loss = val_loss
        torch.save(transformer.state_dict(), "/content/save_temp/best_model.pth")  # Colabにも一時保存(上書き)
        torch.save(transformer.state_dict(), f"{model_dir_path}/best_model_epoch={epoch}_valloss={val_loss:.3f}.pth")  # Google Driveにモデル保存

 

保存したモデルのロード(☆)

ここは、追加学習や推論時に何が必要かをちゃんと理解して、整理しておく必要があります。

'''①以下のセクションをすべて実行する'''
# ランタイム立ち上げ、モジュールインポート(☆)
# ディレクトリの決定、パスの設定(☆)
# トークナイザのロード(☆)
# テキスト→テンソル変換器(☆)
# Transformerモデルの設計(☆)

'''②モデルアーキテクチャ(ハイパーパラメータ)のロード'''
model_arch = torch.load(arch_dir_path + '/model_arch.pth')

'''③モデルのインスタンス構築'''
transformer = Seq2SeqTransformer(num_encoder_layers = model_arch['NUM_ENCODER_LAYERS'],
                                 num_decoder_layers = model_arch['NUM_DECODER_LAYERS'],
                                 emb_size = model_arch['EMB_SIZE'],
                                 nhead = model_arch['NHEAD'],
                                 src_vocab_size = model_arch['SRC_VOCAB_SIZE'],
                                 tgt_vocab_size = model_arch['TGT_VOCAB_SIZE'],
                                 dim_feedforward = model_arch['FFN_HID_DIM'])
transformer.to(DEVICE)

'''④保存済みパラメータをロード'''
transformer.load_state_dict(torch.load(model_dir_path + '/best_model.pth'))

 

パラメータ数とトークン数の確認

別にどうでもいいと言えばどうでもいいですが、モデルのパラメータ数やデータのトークン数をカウントしたいとき用。

# モデルのパラメータ数確認
num_params = sum(p.numel() for p in transformer.parameters())
print(num_params)

# コーパスのトークン数確認(訓練データのみ)
with open(corpus_dir_path + '/corpus_en_train.txt') as f:
    corpus_en = f.readlines()
with open(corpus_dir_path + '/corpus_ja_train.txt') as f:
    corpus_ja = f.readlines()

num_token_en = 0
for text in corpus_en:
    token_ids = subwords_to_ids[SRC_LANGUAGE](text_to_subwords[SRC_LANGUAGE](text))
    num_token_en += len(token_ids)
num_token_ja = 0
for text in corpus_ja:
    token_ids = subwords_to_ids[TGT_LANGUAGE](text_to_subwords[TGT_LANGUAGE](text))
    num_token_ja += len(token_ids)


print("Number of English tokens:", num_token_en)
print("Number of English tokens:", num_token_ja)
print("Number of pairs of parallel corpus:", len(corpus_en))

 

推論の関数(貪欲法)

推論を実施するための関数です。

'''貪欲法での推論の関数'''

# 出力のトークン列を貪欲法で求める
def greedy_decode(model, src, src_mask, max_len, start_symbol):
    src = src.to(DEVICE)
    src_mask = src_mask.to(DEVICE)

    memory = model.encode(src, src_mask)
    ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(DEVICE)
    for i in range(max_len-1):
        memory = memory.to(DEVICE)
        tgt_mask = (generate_square_subsequent_mask(ys.size(0))
                    .type(torch.bool)).to(DEVICE)
        out = model.decode(ys, memory, tgt_mask)
        out = out.transpose(0, 1)
        prob = model.generator(out[:, -1])
        _, next_word = torch.max(prob, dim=1)
        next_word = next_word.item()

        ys = torch.cat([ys,
                        torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0)
        if next_word == EOS_IDX:
            break
    return ys

# 上記関数をつかって翻訳文を生成するラッパー
def translate(model: torch.nn.Module, src_sentence: str):
    model.eval()
    src = text_to_tensor[SRC_LANGUAGE](src_sentence).view(-1, 1)
    num_tokens = src.shape[0]
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool)
    tgt_tokens = greedy_decode(
        model,  src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
    return " ".join(subwords_to_ids[TGT_LANGUAGE].lookup_tokens(list(tgt_tokens.cpu().numpy()))).replace("<bos>", "").replace("<eos>", "").replace(" ", "").replace("▁", "")


'''ビームサーチでの推論の関数'''

import heapq

# ビームサーチにより出力のトークン列を求める
def beam_search_decode(model, src, src_mask, max_len, start_symbol, beam_size):
    src = src.to(DEVICE)
    src_mask = src_mask.to(DEVICE)

    memory = model.encode(src, src_mask)
    initial_input = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(DEVICE)

    # 初期仮説を設定
    hypotheses = [(0.0, initial_input)]
    completed_hypotheses = []

    for _ in range(max_len - 1):
        new_hypotheses = []

        for score, prev_output in hypotheses:
            prev_output = prev_output.to(DEVICE)
            tgt_mask = (generate_square_subsequent_mask(prev_output.size(0))
                        .type(torch.bool)).to(DEVICE)

            out = model.decode(prev_output, memory, tgt_mask)
            out = out.transpose(0, 1)
            prob = model.generator(out[:, -1])

            topk_probs, topk_indices = torch.topk(prob, beam_size)

            for prob, next_word in zip(topk_probs[0], topk_indices[0]):
                new_score = score + prob.item()
                new_output = torch.cat([prev_output, next_word.view(1, 1)], dim=0)
                new_hypotheses.append((new_score, new_output))

        # ビーム内の仮説をスコアでソートして上位のものを保持
        new_hypotheses.sort(reverse=True, key=lambda x: x[0])
        hypotheses = new_hypotheses[:beam_size]

        # EOSトークンがあるかチェック
        completed_hypotheses = [hypothesis for hypothesis in hypotheses if hypothesis[1][-1].item() == EOS_IDX]
        if len(completed_hypotheses) >= beam_size:
            break

    # 最もスコアの高い仮説を取得
    best_hypothesis = max(hypotheses, key=lambda x: x[0])[1]

    return best_hypothesis.flatten()

# 上記関数をつかって翻訳文を生成するラッパー
def translate_beam_search(model: torch.nn.Module, src_sentence: str, beam_size: int):
    model.eval()
    src = text_to_tensor[SRC_LANGUAGE](src_sentence).view(-1, 1)
    num_tokens = src.shape[0]
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool)
    tgt_tokens = beam_search_decode(
        model, src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX, beam_size=beam_size).flatten()
    return " ".join(subwords_to_ids[TGT_LANGUAGE].lookup_tokens(list(tgt_tokens.cpu().numpy()))).replace("<bos>", "").replace("<eos>", "").replace(" ", "").replace("▁", "")

 

推論(翻訳)の実施

いよいよ翻訳をやってみます。

### 推論の実施
# 以下の例文はコーパスに含まれていないことを確認済み。


eng = ['I am a pen.',
       'Your time is limited, so don’t waste it living someone else’s life.',
       'I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character.',
       'You are fake news!',
       'You may say I\'m a dreamer. But I\'m not the only one. I hope someday you\'ll join us. And the world will be as one.',
       'The madman is not the man who has lost his reason. The madman is the man who has lost everything except his reason.',
       'We are being afflicted with a new disease of which some readers may not yet have heard the name, but of which they will hear a great deal in the years to come—namely, technological unemployment. This means unemployment due to our discovery of means of economising the use of labour outrunning the pace at which we can find new uses for labour.  But this is only a temporary phase of maladjustment. All this means in the long run that mankind is solving its economic problem. I would predict that the standard of life in progressive countries one hundred years hence will be between four and eight times as high as it is. There would be nothing surprising in this even in the light of our present knowledge. It would not be foolish to contemplate the possibility of afar greater progress still.'
]

# 貪欲法
for e in eng:
  print(e + '\n' + translate(transformer, e) + '\n')

# ビームサーチ
for e in eng:
  print(e + '\n' + translate_beam_search(transformer, e, 5) + '\n')

*1:有名なのでここで説明する必要はないと思うが、深層学習(ディープラーニング)のアーキテクチャで、2017年にGoogleの“Attention is all you need”という論文で提案され、自然言語処理の性能を革命的に向上させた。

*2:いわゆるLLMの事前学習を行うためのチュートリアルもあって、こちらでは、エンコーダ側とデコーダ側が分かれたクラスが使われてる。まだ詳しくは見てない。Language Modeling with nn.Transformer and torchtext — PyTorch Tutorials 2.0.1+cu117 documentation

*3:以下のブログ記事ではJParaCrawlを全部使って学習した例が紹介されているが、大して性能がよくはなっていない模様。SageMakerでJParaCrawlのコーパスを使って翻訳モデルを作成する

Google Colabでネット接続が切れた時

Google Colabでニューラルネットの学習中に、いかに処理を中断させないかで悩むことは多いと思います。ググるとよく紹介されているのは、
 

  • 12時間や24時間でランタイムがリセットされてしまう問題:これはどうしようもないので、それまでにモデルや設定を保存しておいて、新たに立ち上げたランタイムで学習を継続する。
  • 90分間操作がないとリセットされてしまう問題:シェルスクリプトやブラウザのエクステンションを使って、定期的にページをリロードする。

というものです。
 
ところで私が悩んでいるのは、「パソコンを閉じてネット回線も切った状態で何処かに移動する」時にどうすればいいかという問題です。ふつうに考えたら、90分以内なら実行中のランタイムにもう一度接続できてほしいところなのですが、少なくとも自分の環境(MacのChrome)では、妙な現象が起きます。
何も実行してない状況なら、再接続することで普通に続きから使えるのですが、学習の実行中とかに切ってしまうとおかしくなります。

どういうことかというと、下記画像のように、

  • 右上に「実行中」と表示され、実際にプログラムが動いているにも拘らず、右のほうに「ランタイムに接続していません」と出る。
  • 左のほうをみると、Google Driveのアイコンが未使用状態になっているのに、接続されているディレクトリが表示されており、実際ここにファイルを移したりできる。

という変な状態になります。



そして、右上のメニューから「ホストされているランタイムに接続」を押すと、下の画像のように、左下に「ランタイムに接続できません」という表示がでて、エラーになります。



一応、プログラムは動いている様子で、学習も継続されているのですが(新しくログが出たりするので)、私が確認した範囲だと、学習済みモデルを保存するときにエラーが起きるようです。私の場合、以下のようなエラーが出ます。

ERROR:root:Internal Python error in the inspect module.
Below is the traceback from this internal error.

(省略)

RuntimeError: Parent directory projects/EJTrans_1.3M/model does not exist.

During handling of the above exception, another exception occurred:

(省略)

OSError: [Errno 107] Transport endpoint is not connected


あまり正確に理解できてないのですが、状況から推察すると、Google Driveのマウントが解除されてしまっていて、保存先が見つからないということだと思ます。じゃあGoogle Driveをもう一度マウントすればいいような気がするのですが、操作するとマウント自体はでき(ているように見え)るのですが、なぜか同じエラーが出続けてやはり保存はできません(でも後述の、colabサーバかファイルを移す操作はできます)。


で、対処法ですが、実行中の処理を中断できるなら中断して、保存したいものを、Google DriveではなくGoogle Colab側のサーバに保存します。以下はルートに保存した例。

torch.save(transformer.state_dict(), "/model.pth")

で、いったん保存すると、Colabの画面左側にある「ファイル」のビューアから、ルートまで辿っていくとファイルが保存されてるので、それを上から3つめぐらいにある「content」の中の「drive」の中の「MyDrive」にドラッグすれば、Google Driveに移すことが出来ます。



ただし、下記の画像のように、実行の中断を受けつけてくれない場合があります。これは根気よく待てば中断できたりもしますし、私の経験では、中断のボタンを押してからブラウザをリロードすると、ランタイムの状態を保ったまま処理を中断してくれます。



この処理の中断ができない場合は、保存の操作ができないので、ランタイムを再起動するしかなく、こうなるとどうしようもありません。そういう場合を見越して、最初からモデルをColab側のサーバにも保存しておくのが安全なのかも知れません。実行中のプログラムを停止できなくても、ファイルを移す操作はできたような気がします。


プログラムが実行中かどうか、実行中のプログラムを停止できるかどうか、Google Driveの再マウントができるかどうか等で、いろいろ事象のパターンがかわりそうなのですが、厳密に検証はしてないので、理解が微妙に間違ってるかもしれません。とりあえず言いたかったのは、上述のようなエラーがでてせっかく途中まで学習したデータが失われそうな時に、画面の左側にあるファイルのビューアからcolabサーバ内に保存したデータをGoogle Driveに移すという操作で救える場合がある、ということです。

無償配布の日本語・英語対訳コーパスのデータを綺麗にする

★めぼしいコーパスをまとめてダウンロードしてクリーニングして統合するPythonのコードを最後にのせてます。
★少しソースを増やして、最終的には後日のエントリに書いてあるようなコーパス構成にしました。

日英対訳の(なるべく)綺麗なデータを得たい

Transformerで機械翻訳をやろうと思ったのですが、てっとり早く試すだけの場合、綺麗な演習用データが配布されてる「英語-ドイツ語」や「英語-フランス語」のデータセットを使う人が多いんじゃないでしょうか。
でもそれだと、性能が実感できないので、なんとか「英語 ⇒ 日本語」の機械翻訳を実践したいと思いました。
そこでネックになるのが、

  • 品質がよくて
  • 分量が十分で
  • 無償配布されている

という条件をみたす対訳コーパスが、都合よくあるわけではないということです。


下記のページにいろいろまとまってるのですが、少なくとも私が確認した無償のものに関しては、どれも一長一短という感じで、「これさえ使っておけば」というものがない。
日本語対訳データ


たとえば、NTTの研究所がつくったJParaCrawlってのは2500万件ぐらいあって数としてはすごいのですが、後述のとおり、中身をみると品質にかなり問題があると思いました。
ソフトウェアのマニュアルの対訳なんかは、訳文は正確なんだろうけど、「それ学習してどうすんの」という部分が多い。
法律や公文書の対訳データは、内容が偏っててそれだけでは多様性が足りない。これは京都Wikiなんかにも言える。
その他、品質はいいけど一文が短かったり、内容的には申し分ないけど件数が10万件そこそこしかなかったりもする。


そこで、いくつかのめぼしいコーパスのデータをクリーニングして、使いやすいデータを抽出し、統合して一つの対訳コーパスとして使うことにしました。
本エントリの次のセクションでは、私が確認した各コーパスの内容と処理方針について簡単にメモしておきます。
そして最後のセクションで、ダウンロードから前処理までまとめて実行するコードを載せておきます。


ちなみに私は、使いにくいものは避けて、最終的に以下のものを採用し、合計約128万件の対訳コーパスを作成しました。

  • TED字幕(元データは15万8535件/私は全部使用)
  • 青空文庫等の小説(元データは11万8143件/data augmentationを含めた処理後11万8825件)
  • 京都Wiki(元データは44万3849件/処理後21万8038件)
  • 映画字幕(元は280万1388件/処理後31万3927件)
  • 田中コーパス(元データは14万7918件/私は全部使用)
  • 法令対訳コーパス(元データは26万2449件/処理後18万5607件)
  • JparaCrawl(元データは2574万0835件/私は20万件を使用)

(これを全部合わせると134万2843件で、そこから全体をみて英文400文字、日本語文200文字以内のものに限った結果、128万0621件になった。これは、学習時にミニバッチ内で最長の文に合わせて他の文をパディングする処理をしており、長文が1つでも含まれるとバッチがデカくなりすぎてメモリを圧迫し、そのせいで全体としてバッチサイズを大きくできなくなってしまうため。)


にわかTransformererなので肌感覚がないんですが、この程度の規模だとTransformerの強みが活かされず、LSTMとかのほうがいいのかも?
ただし上記件数は、文が長すぎたり短すぎたりするものを削ったり、一部のデータセットに偏らないように件数を絞って使うことにした結果なので、たとえば映画字幕とJParaCrawlからのサンプルを増やしたりすれば100万単位のコーパスも作れると言えば作れます。
各コーパスについて、詳細は後述しますが、それぞれの大まかな印象は以下のような感じです。

  • TED字幕:品質がとてもよく、口語調のサンプルが手に入るのもいい。
  • 青空文庫等の小説:データの加工が多少必要だが、文章の品質がいい。後述の処理によって比較的長文のサンプルが手に入ってよかった。
  • 京都Wiki:訳文はしっかりしてて件数も多いが、内容が偏り過ぎ。
  • 映画字幕:件数が膨大だが、1つ1つが短く、意訳も多いという癖がある。
  • 田中コーパス:品質がいいだけに、14万件しかないのが惜しい。
  • 法令対訳コーパス:内容が偏るが、品質はとてもいい。あと「長文」サンプルが稼げる。
  • JparaCrawl:件数が2500万と大きいが、品質はいまいち。


できあがった統合コーパスの品質についてですが、ランダムに20件抽出したのものが以下のとおりで、変なのも混じってますがまぁ良いんじゃないでしょうか。

ネルソン・マンデラは、アパルトヘイトの暗黒の日々の後、法の下の人種差別という灰の中から、南アフリカ共和国を引き上げました。彼は性的指向による差別を、憲法内で禁ずる世界初の国へと、南アフリカ共和国を導いたのです。
Nelson Mandela led South Africa after the dark and brutal days of Apartheid, and out of the ashes of that legalized racial discrimination, he led South Africa to become the first country in the world to ban discrimination based on sexual orientation within its constitution.
 
大海人皇子が挙兵のため吉野から東国に出立したときに、皇子に従った二十数人の男の一人であった。
He was one of about two dozen of Prince Oama's followers when the Prince left Yoshino for Togoku to take up army.
 
二つの情熱を組み合わせたんですね。
So, you know, merging the two passions.
 
この事故は、カメラが、一部始終、記録していました。
The entire accident is caught on camera.
 
そこへ行っていただけませんか。
Will you please go there?
 
それをやってみるなんて狂気に近い。
It is little short of lunacy to try it.
 
彼女からの手紙が郵便受けにはいっていた。
I found her letter in the mailbox.
 
明治維新後はこうした規定は廃止され、各宗派それぞれの規定に基づいて(大抵は得度時に)度牒を交付する事になっている。
After the Meiji Restoration, these provisions were abolished and each sect has issued Docho official certificates (mostly at the time of entrance into the priesthood) on the basis of their respective stipulations.
 
全列車JR西日本223系電車1000番台及びJR西日本223系電車2000番台電車使用で、8両または12両編成で運転。
All trains use the cars of the JR (West) Suburban Train Series 223, model No. 1000s or the JR (West) Suburban Train Series 223, model No. 2000s, and they're operated in units of eight or 12 cars.
 
殆どが状況証拠だった、物的証拠が、中々見つからない
at first, the evidence was mostly circumstantial, physical proof of the murders being hard to come by.
 
それ自体が実質的に社会の一員としてふるまう
how might we think differently about our relations with technologies
 
これは大変なことです。マウス細胞だけでなく、ヒトの皮膚細胞から、ヒト幹細胞を作れるからです。
That turns out to be a big deal because now you can take, not just mouse cells, but you can human skin cells and turn them into human stem cells.
 
見落としてたから、他にもいくつか、見落としがあったわ
ms. wick overlooked them, as she did much else in this case.
 
ライフ・ストリーム・ドラゴンは、シンクロ召喚に成功したとき
when i successfully synchro summon life stream dragon
 
礫の一部を打撃して造るチョッパー・チョッピングツールを主体とする。
Its main items were Choppers and Chopping tools which were produced by beating a part of a pebble.
 
(拍手)、そしてアップル愛好家がいるのもわかってますよ。
(Applause) And I know some of you are Apple aficionados.
 
私はバスの中で財布を取られた。
I had my wallet stolen on the bus.
 
私は暫く待つように言われた。
I was told to wait for a while.
 
胆のうの手術でしたら、この廊下をずっと行って右に曲がってください。
For gall bladder surgery, go down this hall and take a right.
 
彼女の住所を教えてくれませんか。
Will you give me her address?


少なくとも、どれか1つのコーパスを単独で使うよりは遥かにいいと感じられるので、満足してます。
長いものから短いものまで、書き言葉から話し言葉まで、多様性もあるし(といっても長い例文は不足してますが)。
無償コーパスの中身を自分で精査してみた人でないと実感が湧かないとは思うのですが、とにかく私は、JParaCrawlの品質に絶望したあとなので、これをみるとオアシスのように感じられますw
ちなみにここから5万件ぐらいだけ抽出してTransformerにかけてみたら、小規模でも思ったよりそれらしい出力が出ました。全データ使った学習は後でやります。
 
 

各コーパスの性質や処理方針についてのメモ

TEDの字幕

The Multitarget TED Talks Task (MTTT)

TEDトークの字幕のデータで、口語調なのだが、かなり整っていて品質は高いと思える。
あと、短いセリフはいくつかまとめて1行になっている点もよい。対訳コーパスは1文が短すぎるものが多く、文脈を考慮して意味を読み取れるTransformerの力が発揮できない気がするので。
日本語文をみると、以下のような感じで、句読点がなく半角スペース区切りになっている。

私たちのミッションの多くは 写真やビデオを撮影するものです 撮影が始まってしまえば たいていは コーヒーを取りに行き しばらくは のんびりと待つのです のんびりとできないで 無人機が戻らなかったらどうしようと パニックになる人もいますけどね\n

適当に読点や句点を入れる必要があるんですが、文末は句点だとしても、文中のすべてを読点にすればいいわけではないのが難しいところ。
そこで、

  • 文中で「ね 」「です 」「ます 」等の場合は句点を入れる。
  • 文中でそれ以外は読点を入れる。
  • 文末は句点を入れる。

という変換をやってみたところ、全体的にかなりいい感じになった。
 

青空文庫等の小説

https://www2.nict.go.jp/astrec-att/member/mutiyama/align/index.html

青空文庫、杉田玄白プロジェクト、グーテンベルク・プロジェクトなど、小説のオープンなデータのうち、日英の対訳が揃っているもののデータらしい。
日本語分はChasenで分かち書きされていて、単語間にスペースが入っているのだが、これはTransformerで解釈する上では不要なので後で消す。
文献情報や注釈など、翻訳の学習によくなさそうなデータも混じっているのだが、流石に小説として読めるテキストだけあって、文章は綺麗である。
ただし、小説なので当然「意訳」されているところもあってそれがネックな感じはした。
あとなぜか、1文1行になっていないところがけっこうあり、ひと続きの文なのに2行に分かれてたりする。
また、1文1行になっていても、それが理想的なわけではなく、前後の文脈がないと意味が取れない文もある。
そこで、以下のようにクリーニングした。

  • 日本語なのに英数字がたくさん入っている文を削除(注釈とかなので)。
  • 英文が大文字で始まっていないところは、前の文と続けるようにした。
  • 文脈の連続性を多少でも考慮できるように、全体的に2文ずつセットで1文にしてまとめた(なのでサンプルサイズが半分になる)。強引な処理だが、これによって「長文」のサンプルが稼げるメリットもあるように思った。さきほど言ったように、対訳コーパスは短すぎるものが多いので。
  • で、あとで思いついたのだが、ペアにする組み合わせをずらすことで別の文をつくることができる。情報としては重複しているが翻訳タスク上は別のサンプルだとも言えるので、これでdata augmentationになる。そこでずらしたバージョンを追加し、さらに2文連結する前の短い文も残すことして、結果的に11万件ぐらいのコーパスになった。

 

京都Wiki

京都フリー翻訳タスク

京都に関連するWikipediaの情報を集めて英訳したものらしい。
翻訳はしっかりしている印象なのだが、仏教や歴史の説明が多くて話題が偏っているので、このコーパスだけ学習してもあまり意味がない気がする。
あと、たとえば以下のようなリスト的な情報もあり、これ訳してどうすんのと思ってしまう。

『正法眼蔵』(しょうぼうげんぞう)
『正法眼蔵,正法眼蔵随聞記日本古典文学大系81』(西尾実ほか校注.岩波書店,1965年)
『現代語訳正法眼蔵(一)~(一二)』(西嶋和夫訳.金沢文庫,1970年)
『正法眼蔵(一)~(四)』(水野弥穂子校注.岩波文庫,1990年)
『正法眼蔵』(石井恭二訳、河出文庫、2004年)
『永平広録』(石井恭二訳、河出書房新社、2005年)
『永平清規』
『典座教訓』(てんぞきょうくん)
『典座教訓 赴粥飯法』(中村璋八ほか全訳注.講談社学術文庫,1991年)
『赴粥飯法』(ふしゅくはんほう)
『正法眼蔵随聞記』(しょうぼうげんぞうずいもんき)懐奘編-道元の講義録。
『正法眼蔵随聞記 新校註解』(大久保道舟校註.山喜房仏書林,1958年)
『正法眼蔵随聞記』(古田紹欽訳注.角川文庫,1960年)
『正法眼蔵随聞記』(和辻哲郎校訂.岩波文庫,1982年改版)
『正法眼蔵随聞記』(水野弥穂子訳.ちくま学芸文庫,1992年)
『正法眼蔵随聞記 現代語訳』(池田魯参訳.大蔵出版,1993年)
『正法眼蔵随聞記』(山崎正一全訳注.講談社学術文庫,2003年)

訳文はたぶん綺麗なほうで件数も多いのだが、内容が偏っているという感じなので、全件は使わず、短すぎる文と長すぎる文を除いて絞ることとした。(長いほうは除きたい理由が具体的にあるわけではないのだが、どのコーパスにも言えることとして、めちゃめちゃ長い行はなんかよくわからん情報が入っていることが多い。Wikipediaだし。)

 

映画の字幕

JESC

映画の字幕の対訳データで、件数がすごく多い(280万件)のと、データのフォーマットが綺麗で使いやすいところがメリットだと思う。
ただし、日本語で句読点が付いてない文が多く、文と文の切れ目が半角スペースで区切られている。これをどう処理するか、真面目に考えると難しいのだが、面倒なのでとりあえず全部読点で置き換えることにした。
また、想像すれば分かると思うが、映画の字幕なのでかなり意訳が混じっており、日本語と英語が素直に対応していない部分も多い。
あと、これも字幕だから当然なのだが、全体的に文が短い。
元の件数が非常に多いので、文の長さが一定以上の長さのものだけ抽出。
 

田中コーパス

Tanaka Corpus - EDRDG Wiki

どういう由来のものかよく調べてないのだが、自分が見た中では、かなり品質のいい対訳データだと感じた。
文章が整っていて、翻訳も正確。ただ残念ながら1文1文が短くて、語学の教材の典型的な例文という感じ。
フォーマットの整理だけして、全て使うことにした。
 

JParaCrawl

JParaCrawl

これが最もくせものだと思う。
ウェブからクロールしてきたデータで、件数が2500万件もあるので、内容の多様さと規模が魅力だから、そこだけ聞くと使いたくなる。
コーパス作成手順としては、日本語と英語が半々に近い分量含まれているサイトをまず集めて、そこから内容的に対応してそうな日本文・英文のペアを自動で抽出しているらしい。


ところが中身をみてみると、割と品質が低いと言わざるを得ない感じ。そもそも「できの悪い機械翻訳」みたいな訳文がたくさん含まれているし、意味的に全然対応していないペアも含まれている。
で、明確な説明はみつからなかったのだが、どうやら対訳としての品質(意味の類似度?)のようなスコアがすべてのペアについている。じゃあそのスコアが高い文だけ使えば良いのでは?と思ったものの、スコアが高い文をみてみると、

ゆんフリー写真素材集 : No. 9877 草津温泉 湯畑 [日本 / 群馬]

という商品名みたいなやつが大量に並んでいる部分もあって、なかなか難しい。
ただ、スコア0.5から0.8までを0.5刻みで階級化し、各階級から30の例文をランダムに取って、ChatGPTに与えて「適切な訳ならgood、不適切ならbad」と評価するよう頼んでみたところ(横着な作業だが…)、やっぱり高スコアのほうがgoodは多かった。そこで、0.7以上のデータを使うことにして、長すぎるものと短すぎるものを削ったり、日本文に英字が含まれているものを削ったり、英語が大文字で始まってないもの、日本語が。でおわっていないもの等を削除してある程度クリーニングし、そこから20万件だけを選んで使うことにした。

 

ダウンロード、整理統合のPythonコード

以下に、ファイルのダウンロード、クリーニング、件数絞り、ファイル統合などの前処理をまとめたコードを置いておきます。
スマートな書き方は追求してなくて、記述が冗長になってるし、部分的に変数名が重複してたりもするかも知れません。
でも上から順に実行する分にはとりあえず問題ないです。さっき動作確認のために、Google Colab上でまとめて動かしてみたら、TEDとJParaCrawlのダウンロードに時間がかかるものの、他の処理は一瞬でした。
冒頭で作業ディレクトリのパスを指定するだけで、あとはまとめて実行でOKです。


最終的に、日本語と英語のそれぞれのファイルができますが、行ごとに訳文として対応しています。
コーパス順とかに並んでるのではなく、シャッフルしてます(学習で使うときにどうせシャッフルするため)。


京都、映画字幕は一定の字数範囲に収まるサンプルに限る処理でけっこう件数が減っているし、JParaCrawlはクリーニング後には367万件ほど残っていたのを20万件だけサンプルしているので、増やしたい場合はこのあたりで調整できると思います。


全体の量も増やしたいところではありますが、最も改善が必要なのは「長文」のサンプルが足りない点。これは、法令の対訳データが長文なので使うと良いかもしれない(以下のリンクは憲法の例)。まとめて抜く方法が分からなかったので今回は採用しなかったけど。
The Constitution of Japan - Japanese/English - Japanese Law Translation


……と思ったら法令の対訳をまとめたコーパスがあったので使うことにしました。
全件で26万2449件あり、短い文を除いて使うことにしました。
日英法令対訳コーパス

 

'''
全体の設定
'''

# Google Driveにマウント
from google.colab import drive
drive.mount('/content/drive')

# 作業ディレクトリ変更
import os
import sys
import re
import random
os.chdir('作業ディレクトリのパスを入れる') 
# Google ColabにGoogle Driveをマウントして使ってる時は、パスは以下のようになる。
# /content/drive/MyDrive/xxxxxxxxxxxx


'''
TED talkのデータセット
https://www.cs.jhu.edu/~kevinduh/a/multitarget-tedtalks/
'''

### ダウンロードと解凍(強制上書き)
!mkdir -p ted
!wget -O ted/multitarget-ted.tgz https://www.cs.jhu.edu/~kevinduh/a/multitarget-tedtalks/multitarget-ted.tgz
!tar -xzf ted/multitarget-ted.tgz -C ted/

### ファイル読み込み
# 本データセットは\n付いたまま処理する。
with open('ted/multitarget-ted/en-ja/raw/ted_train_en-ja.raw.ja', errors='ignore') as f:
    ted_lines_ja_train = f.readlines()
with open('ted/multitarget-ted/en-ja/raw/ted_train_en-ja.raw.en', errors='ignore') as f:
    ted_lines_en_train = f.readlines()
with open('ted/multitarget-ted/en-ja/raw/ted_test1_en-ja.raw.ja', errors='ignore') as f:
    ted_lines_ja_test = f.readlines()
with open('ted/multitarget-ted/en-ja/raw/ted_test1_en-ja.raw.en', errors='ignore') as f:
    ted_lines_en_test = f.readlines()
with open('ted/multitarget-ted/en-ja/raw/ted_dev_en-ja.raw.ja', errors='ignore') as f:
    ted_lines_ja_dev = f.readlines()
with open('ted/multitarget-ted/en-ja/raw/ted_dev_en-ja.raw.en', errors='ignore') as f:
    ted_lines_en_dev = f.readlines()

### リスト連結
ted_lines_ja = ted_lines_ja_train + ted_lines_ja_test + ted_lines_ja_dev
ted_lines_en = ted_lines_en_train + ted_lines_en_test + ted_lines_en_dev

len(ted_lines_ja)

# サンプルを確認
for i in random.sample(list(range(len(ted_lines_ja))), 5):
  print(ted_lines_ja[i])
  print(ted_lines_en[i])

### 句読点の挿入

'''
日本語が以下のような感じで、句読点がなく半角スペース区切りになっている。

私たちのミッションの多くは 写真やビデオを撮影するものです 撮影が始まってしまえば たいていは コーヒーを取りに行き しばらくは のんびりと待つのです のんびりとできないで 無人機が戻らなかったらどうしようと パニックになる人もいますけどね\n

文中でも句点を入れたほうがよさそうだが、難しいので、
①文中で「ね 」「です 」「ます 」等は句点を入れる。
②文中でそれ以外は読点を入れる。
③文末は句点を入れる。
これでだいぶきれいになる。
'''

ted_lines_ja = [l.replace("です ", "です。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ます ", "ます。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("でした ", "でした。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ました ", "ました。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ません ", "ません。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("でしょう ", "でしょう。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("でしょうか ", "でしょうか。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ますか ", "ますか。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ませんか ", "ませんか。") for l in ted_lines_ja]
ted_lines_ja = [l.replace("ね ", "ね。") for l in ted_lines_ja]
ted_lines_ja = [l.replace(" ", "、") for l in ted_lines_ja]
ted_lines_ja = [l.replace("\n", "。\n") for l in ted_lines_ja]

print(len(ted_lines_ja))
print(len(ted_lines_en))

# 長さ確認(異常なサンプルがないか)
print(max([len(l) for l in ted_lines_ja]))
print(min([len(l) for l in ted_lines_ja]))
print(max([len(l) for l in ted_lines_en]))
print(min([len(l) for l in ted_lines_en]))

# 対訳の確認(念のため)
for i in random.sample(list(range(len(ted_lines_ja))), 10):
  print(ted_lines_ja[i])
  print(ted_lines_en[i])

### 保存
with open("ted/ted_clean_ja.txt", "w") as file:
    for l in ted_lines_ja:
        _ = file.write(l)
with open("ted/ted_clean_en.txt", "w") as file:
    for l in ted_lines_en:
        _ = file.write(l)


'''
青空文庫等の学習データ(NICT)の取得とクリーニング
https://www2.nict.go.jp/astrec-att/member/mutiyama/align/index.html
Project Gutenberg や青空文庫やプロジェクト杉田玄白などの作品について,日本語文と英語文との対訳文対応を付けたもの。
'''

### ダウンロードと解凍
!mkdir -p aozora
!wget -O aozora/para.zip https://www2.nict.go.jp/astrec-att/member/mutiyama/align/download/para.zip
!unzip -o aozora/para.zip -d aozora/

### まず日本語のほうの文字コードをutf-8にして保存
# 一部なぜか読めない文字列があるみたいなのでignore(スキップ)
with open('aozora/para/ja.txt', 'r',encoding='shift_jis', errors='ignore') as f:
    text_jp = f.read()
with open('aozora/para/ja_utf8.txt', 'w', encoding="utf-8") as f:
    f.write(text_jp)

### 1行ずつのリスト形式で読み込み
with open('aozora/para/ja_utf8.txt', errors='ignore') as f:
    aozora_lines_ja = f.readlines()
with open('aozora/para/en.txt', errors='ignore') as f:
    aozora_lines_en = f.readlines()

len(aozora_lines_ja)

# サンプルを確認
for i in random.sample(list(range(len(aozora_lines_ja))), 5):
  print(aozora_lines_ja[i])
  print(aozora_lines_en[i])

### 基本的なクリーニング

# 日本語はChasenで分かち書きされてるのでくっつける。
aozora_lines_ja = [l.replace(' ','').strip() for l in aozora_lines_ja]

# 英語は末尾の\nだけ削る
aozora_lines_en = [l.strip() for l in aozora_lines_en]

# 英語と日本語のどちらもアイテムがあるものだけ抽出する
valid_ix = [i for i in range(len(aozora_lines_ja)) if (len(aozora_lines_ja[i]) > 0 and len(aozora_lines_en[i]) > 0)]
aozora_lines_ja = [aozora_lines_ja[i] for i in valid_ix]
aozora_lines_en = [aozora_lines_en[i] for i in valid_ix]

### 分断された文の結合
'''
1文が2行に分けてある部分もあり、それは行頭が大文字かどうかで判断できるので、
英語で行頭が大文字じゃない(カッコとかも含む)ところはくっつける
ただし作品名とかが
################/tempest.alml/################みたいに
入ってる部分は除く
'''
for i in range(len(aozora_lines_en)):
  if (aozora_lines_en[i][0].isupper() == False) and (aozora_lines_en[i][0] != '#'):
    aozora_lines_en[i] = aozora_lines_en[i-1] + ' ' + aozora_lines_en[i]
    aozora_lines_en[i-1] = ''
    aozora_lines_ja[i] = aozora_lines_ja[i-1] + aozora_lines_ja[i]
    aozora_lines_ja[i-1] = ''

# 結合されたセンテンスがおかしくないか確認
merged_en = [aozora_lines_en[i] for i in range(len(aozora_lines_en)) if len(aozora_lines_en[i])>0 and len(aozora_lines_en[i-1])==0]
merged_ja = [aozora_lines_ja[i] for i in range(len(aozora_lines_ja)) if len(aozora_lines_ja[i])>0 and len(aozora_lines_ja[i-1])==0]
for i in random.sample(list(range(len(merged_en))), 3):
  print(merged_en[i])
  print(merged_ja[i])

# 空の要素を削除
valid_ix = [i for i in range(len(aozora_lines_ja)) if (len(aozora_lines_ja[i]) > 0 and len(aozora_lines_en[i]) > 0)]
aozora_lines_ja = [aozora_lines_ja[i] for i in valid_ix]
aozora_lines_en = [aozora_lines_en[i] for i in valid_ix]

print(len(aozora_lines_ja))
print(len(aozora_lines_en))

### URLが入ってるところはだいたいおかしいので削る
target_ix = [i for i in range(len(aozora_lines_ja)) if ('http://' in aozora_lines_ja[i]) or ('https://' in aozora_lines_ja[i])]

# 降順にソートしてからpopで削除
ix_sorted = sorted(target_ix, reverse=True)
for ix in ix_sorted:
    aozora_lines_ja.pop(ix)
    aozora_lines_en.pop(ix)

### 英字まじりの文をまとめて削除

'''
いろいろ見直した結果、日本文に英字が10文字以上連続で入ってるやつは
全部削除したほうがよさそうなので消す。
この操作、
################/tortoise.alml/################
みたいな作品タイトル行も除去できる。
'''

target_ix = [i for i in range(len(aozora_lines_ja)) if bool(re.search(r'[a-zA-Z,\.!?\"\':; ]{10,}', aozora_lines_ja[i]))]
ix_sorted = sorted(target_ix, reverse=True)
for ix in ix_sorted:
    aozora_lines_ja.pop(ix)
    aozora_lines_en.pop(ix)

### 適当に文を連結

'''
元が小説のデータなので、文脈上連続している文が多く、その連続を踏まえないと、
「he」を具体的な名前で和訳している箇所の意味が分からなかったりする。
そこで、適当だが、2文ずつ連結することで、全体的にやや長い対訳サンプルにする。
ただしそれをやるとサンプルが半分になって3万件程度になる。
そこで、連結するペアをずらすことで2パターン作成し、さらに連結前の文も残すことで、
一種のdata augmantationを行う(全く同じ文はないことになるのでOK。)
このコーパスは質が高いので、多めに取っておきたいというのもある。
'''

aozora_lines_en_Concat1 = [aozora_lines_en[i] + ' ' + aozora_lines_en[i + 1] for i in range(0, len(aozora_lines_en) - 1, 2)]
aozora_lines_en_Concat2 = [aozora_lines_en[i] + ' ' + aozora_lines_en[i + 1] for i in range(1, len(aozora_lines_en) - 1, 2)]
aozora_lines_ja_Concat1 = [aozora_lines_ja[i] + aozora_lines_ja[i + 1] for i in range(0, len(aozora_lines_ja) - 1, 2)]
aozora_lines_ja_Concat2 = [aozora_lines_ja[i] + aozora_lines_ja[i + 1] for i in range(1, len(aozora_lines_ja) - 1, 2)]

aozora_lines_en = aozora_lines_en + aozora_lines_en_Concat1 + aozora_lines_en_Concat2
aozora_lines_ja = aozora_lines_ja + aozora_lines_ja_Concat1 + aozora_lines_ja_Concat2

# 対訳の確認(念のため)
for i in random.sample(list(range(len(aozora_lines_ja))), 10):
  print(aozora_lines_ja[i])
  print(aozora_lines_en[i])

print(len(aozora_lines_ja))
print(len(aozora_lines_en))

# クリーニング済みのテキストを保存
with open("aozora/aozora_clean_ja.txt", "w") as file:
    for l in aozora_lines_ja:
        _ = file.write(l+'\n')
with open("aozora/aozora_clean_en.txt", "w") as file:
    for l in aozora_lines_en:
        _ = file.write(l+'\n')


"""
京都フリー翻訳タスク
http://www.phontron.com/kftt/index-ja.html
"""

### ダウンロードと解凍(強制上書き)
!mkdir -p kyoto
!wget -O kyoto/kftt-data-1.0.tar.gz http://www.phontron.com/kftt/download/kftt-data-1.0.tar.gz
!tar -xzf kyoto/kftt-data-1.0.tar.gz -C kyoto/

### ファイル読み込み
# 本データセットは\n付いたまま処理する。
with open('kyoto/kftt-data-1.0/data/orig/kyoto-train.ja', errors='ignore') as f:
    kyoto_lines_ja_train = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-train.en', errors='ignore') as f:
    kyoto_lines_en_train = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-dev.ja', errors='ignore') as f:
    kyoto_lines_ja_dev = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-dev.en', errors='ignore') as f:
    kyoto_lines_en_dev = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-test.ja', errors='ignore') as f:
    kyoto_lines_ja_test = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-test.en', errors='ignore') as f:
    kyoto_lines_en_test = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-tune.ja', errors='ignore') as f:
    kyoto_lines_ja_tune = f.readlines()
with open('kyoto/kftt-data-1.0/data/orig/kyoto-tune.en', errors='ignore') as f:
    kyoto_lines_en_tune = f.readlines()

### リスト連結
kyoto_lines_ja = kyoto_lines_ja_train + kyoto_lines_ja_dev + kyoto_lines_ja_test + kyoto_lines_ja_tune
kyoto_lines_en = kyoto_lines_en_train + kyoto_lines_en_dev + kyoto_lines_en_test + kyoto_lines_en_tune

len(kyoto_lines_ja)

# サンプルを確認
for i in random.sample(list(range(len(kyoto_lines_ja))), 5):
  print(kyoto_lines_ja[i])
  print(kyoto_lines_en[i])

### 日本語で35文字以上200文字未満を取る。
target_ix = [i for i in range(len(kyoto_lines_ja)) if (len(kyoto_lines_ja[i]) >= 35) and (len(kyoto_lines_ja[i]) <= 200)]
kyoto_lines_ja = [kyoto_lines_ja[i] for i in target_ix]
kyoto_lines_en = [kyoto_lines_en[i] for i in target_ix]

# 対訳の確認(念のため)
for i in random.sample(list(range(len(kyoto_lines_ja))), 5):
  print(kyoto_lines_ja[i])
  print(kyoto_lines_en[i])

print(len(kyoto_lines_ja))
print(len(kyoto_lines_en))

### 保存
# クリーニング済みのテキストを保存
with open("kyoto/kyoto_clean_ja.txt", "w") as file:
    for l in kyoto_lines_ja:
        _ = file.write(l)
with open("kyoto/kyoto_clean_en.txt", "w") as file:
    for l in kyoto_lines_en:
        _ = file.write(l)


"""
映画の字幕
https://nlp.stanford.edu/projects/jesc/index.html
"""

### ダウンロードと解凍(強制上書き)
!mkdir -p subtitle
!wget -O subtitle/raw.tar.gz https://nlp.stanford.edu/projects/jesc/data/raw.tar.gz
!tar -xzf subtitle/raw.tar.gz -C subtitle/

### 読み込み
with open('subtitle/raw/raw') as f:
    subtitle_lines = f.readlines()

### 日本語と英語に分ける
subtitle_lines_en = [l.strip().split('\t')[0] for l in subtitle_lines]
subtitle_lines_ja = [l.strip().split('\t')[1] for l in subtitle_lines]

len(subtitle_lines_ja)

# サンプルを確認
for i in random.sample(list(range(len(subtitle_lines_ja))), 5):
  print(subtitle_lines_ja[i])
  print(subtitle_lines_en[i])

### 読点を入れる
subtitle_lines_ja = [l.replace(" ", "、") for l in subtitle_lines_ja]

# 対訳の確認(念のため)
for i in random.sample(list(range(len(subtitle_lines_ja))), 5):
  print(subtitle_lines_ja[i])
  print(subtitle_lines_en[i])

### 日本語で25文字以上のものに限定する
target_ix = [i for i in range(len(subtitle_lines_ja)) if (len(subtitle_lines_ja[i]) >= 25)]
subtitle_lines_ja = [subtitle_lines_ja[i] for i in target_ix]
subtitle_lines_en = [subtitle_lines_en[i] for i in target_ix]

print(len(subtitle_lines_ja))
print(len(subtitle_lines_en))

### 保存
# クリーニング済みのテキストを保存
with open("subtitle/subtitle_clean_ja.txt", "w") as file:
    for l in subtitle_lines_ja:
        _ = file.write(l+'\n')
with open("subtitle/subtitle_clean_en.txt", "w") as file:
    for l in subtitle_lines_en:
        _ = file.write(l+'\n')


"""
田中コーパス
http://www.edrdg.org/wiki/index.php/Tanaka_Corpus
"""

### ダウンロードと解凍(強制上書き)
!mkdir -p tanaka
!wget -O tanaka/examples.utf.gz ftp://ftp.edrdg.org/pub/Nihongo/examples.utf.gz
!gunzip -f tanaka/examples.utf.gz

### 読み込み
with open('tanaka/examples.utf') as f:
    tanaka_lines = f.readlines()

# B: で始まる行を削除
tanaka_lines = [l for l in tanaka_lines if l.startswith('A: ')]

# 先頭の'A: 'の部分を削除
tanaka_lines = [l.replace('A: ', '', 1) for l in tanaka_lines]

# '#ID' 以降の部分を削除
tanaka_lines = [l.split('#ID')[0].strip() for l in tanaka_lines]

### 日本語と英語に分ける
tanaka_ja = [l.split('\t')[0] for l in tanaka_lines]
tanaka_en = [l.split('\t')[1] for l in tanaka_lines]

len(tanaka_ja)

# 対訳の確認(念のため)
for i in random.sample(list(range(len(tanaka_ja))), 5):
  print(tanaka_ja[i])
  print(tanaka_en[i])

### 保存
# クリーニング済みのテキストを保存
with open("tanaka/tanaka_clean_ja.txt", "w") as file:
    for l in tanaka_ja:
        _ = file.write(l+'\n')
with open("tanaka/tanaka_clean_en.txt", "w") as file:
    for l in tanaka_en:
        _ = file.write(l+'\n')


"""
日英法令対訳
http://www.phontron.com/jaen-law/index-ja.html
"""

### ダウンロードと解凍(強制上書き)
!mkdir -p law
!wget -O law/jaen-law.tar.gz http://www.phontron.com/jaen-law/jaen-law.tar.gz
!tar -xzf law/jaen-law.tar.gz -C law/

# ファイル読み込み
with open('law/jaen-law/txt/law-corpus.en') as f:
    law_en_lines = f.readlines()
with open('law/jaen-law/txt/law-corpus.ja') as f:
    law_ja_lines = f.readlines()

print(len(law_en_lines))
print(len(law_ja_lines))

### 日本語で20文字以上の行に限定する
target_ix = [i for i in range(len(law_ja_lines)) if (len(law_ja_lines[i]) >= 20)]
law_ja_lines = [law_ja_lines[i] for i in target_ix]
law_en_lines = [law_en_lines[i] for i in target_ix]

print(len(law_en_lines))
print(len(law_ja_lines))

# 対訳の確認(念のため)
for i in random.sample(list(range(len(law_en_lines))), 5):
  print(law_en_lines[i])
  print(law_ja_lines[i])

### 保存
# クリーニング済みのテキストを保存
with open("law/law_clean_ja.txt", "w") as file:
    for l in law_ja_lines:
        _ = file.write(l)
with open("law/law_clean_en.txt", "w") as file:
    for l in law_en_lines:
        _ = file.write(l)


"""
JParaCrawl(NTT)の取得とクリーニング
http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl/
"""

### ダウンロードと解凍(強制上書き)
!mkdir -p jparacrawl
!wget -O jparacrawl/en-ja.tar.gz http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl/release/3.0/bitext/en-ja.tar.gz
!tar -xzf jparacrawl/en-ja.tar.gz -C jparacrawl/

# 全行を読み込みする場合(読み込み自体は数十秒ぐらいでできる)
with open('jparacrawl/en-ja/en-ja.bicleaner05.txt') as f:
    crawl_lines = f.readlines()

# サンプルを確認
for l in random.sample(crawl_lines, 5):
  print(l.strip().split('\t')[-1])
  print(l.strip().split('\t')[-2])

'''
# 数値のところの分布を確認すると以下のようになったが、
# 0.79以上みたいなスコアが高いものに限定してしまうと、
# ゆんフリー写真素材集 : No. 9877 草津温泉 湯畑 [日本 / 群馬]
# みたいなのばっかりになるのでよくないっぽい。
# サンプル総数:25,740,835
# 0.4以上0.5未満:0.0
# 0.5以上0.6未満:0.1152207377888091
# 0.6以上0.7未満:0.16798157480128365
# 0.7以上0.8未満:0.7167976874099072
# 0.75以上0.8未満:0.3224694925397719
# 0.75以上0.775未満:0.2583718049550452(8,300,634件)
# 0.76以上:0.20323936655512534(5,231,551件)
# 0.77以上:0.09364913764452475(2,410,607件)
# 0.775以上:0.06409768758472675(1,649,928件)
# 0.78以上:0.031290127146225054(805,434件)
# 0.785以上:0.009059340926586104(233,195件)
# 0.79以上:0.0011227297016588624(28,900件)
# 0.8以上:0.0

scores = [float(l.strip().split('\t')[2]) for l in crawl_lines]
'''

'''
# どのスコア帯のサンプルが比較的健全であるかを目視確認

min_score = 0.75
max_score = 0.80
ranged_lines = [l for l in crawl_lines if float(l.strip().split('\t')[2]) >= min_score and float(l.strip().split('\t')[2]) <= max_score]
for l in random.sample(ranged_lines, 5):
  print(l.strip().split('\t')[-1])
  print(l.strip().split('\t')[-2])
  print('\n')
'''

'''
スコア0.6から0.8まで、0.05刻みで階級をつくり、各階級から30の対訳ペアをランダムに
抽出して、ChtGPTに「適切な訳かどうか」をgood/badで評価させたところ、以下のとおり。

0.50-0.55 14 GOODs
0.55-0.60 18 GOODs
0.60-0.65 18 GOODs
0.65-0.70 21 GOODs
0.70-0.75 21 GOODs
0.75-0.80 26 GOODs

本当はもっと精査したほうがいいと思うが、まず0.65以上に絞った上で、
・日本語文が短いものと長いものを削除
・日本語文の中に3文字以上の連続する英字が混じっているものを削除
・日本語文が句点でおわっていないものを削除
・英文が大文字で始まっていないものを削除
・日本語文と英語文の長さの比が極端なものを削除
することにする。
'''

### クリーニング用に範囲内のサンプルを抽出
min_score = 0.70
max_score = 0.80
crawl_lines = [l for l in crawl_lines if float(l.strip().split('\t')[2]) >= min_score and float(l.strip().split('\t')[2]) <= max_score]

### 日本語と英語に分ける
crawl_ja = [l.strip().split('\t')[-1] for l in crawl_lines]  # 一番うしろが日本語
crawl_en = [l.strip().split('\t')[-2] for l in crawl_lines]  # うしろから2番目が英語

'''
ヒストグラムで日本語文と英語文の長さの比の分布を確認
length_ratio = [(len(crawl_en[i]) / len(crawl_ja[i])) for i in range(len(crawl_en))]
import matplotlib.pyplot as plt
import numpy as np
bins = np.arange(0, 4.2, 0.2).tolist()

plt.hist(length_ratio, bins=bins, rwidth=0.8)
plt.show()
'''

### 長さの比が極端でないものだけ残す
target_ix = [i for i in range(len(crawl_en)) if (len(crawl_en[i]) / len(crawl_ja[i])) > 1.8 and (len(crawl_en[i]) / len(crawl_ja[i]))  < 2.6]

crawl_en = [crawl_en[i] for i in target_ix]
crawl_ja = [crawl_ja[i] for i in target_ix]

print(len(crawl_en))
print(len(crawl_ja))

### 日本文が長いものと短いものを削除
medium_length_ix = [i for i in range(len(crawl_ja)) if (len(crawl_ja[i]) > 30) and (len(crawl_ja[i]) < 300)]
crawl_ja = [crawl_ja[i] for i in medium_length_ix]
crawl_en = [crawl_en[i] for i in medium_length_ix]

print(len(crawl_en))
print(len(crawl_ja))

### 日本文が句点で終わるものだけ抽出
target_ix = [i for i in range(len(crawl_ja)) if crawl_ja[i][-1] == '。']

crawl_en = [crawl_en[i] for i in target_ix]
crawl_ja = [crawl_ja[i] for i in target_ix]

### 英文が大文字で始まるものだけ抽出
target_ix = [i for i in range(len(crawl_en)) if crawl_en[i][0].isupper()]

crawl_en = [crawl_en[i] for i in target_ix]
crawl_ja = [crawl_ja[i] for i in target_ix]

### 日本文に英字を含まないものだけ抽出
target_ix = [i for i in range(len(crawl_ja)) if not re.search(r'[a-zA-Z!@#$%^&*()-=_+[\]{};:\'",.<>?/|\\]', crawl_ja[i])]
crawl_en = [crawl_en[i] for i in target_ix]
crawl_ja = [crawl_ja[i] for i in target_ix]

'''
### 日本文に連続する3文字以上の英字部分を含むものを削除
target_ix = [i for i in range(len(crawl_ja)) if bool(re.search(r'[a-zA-Z,\.!?\"\':; ]{3,}', crawl_ja[i]))]
ix_sorted = sorted(target_ix, reverse=True)
for ix in ix_sorted:
    crawl_ja.pop(ix)
    crawl_en.pop(ix)
'''

print(len(crawl_en))
print(len(crawl_ja))

# 対訳の確認(念のため)
for i in random.sample(list(range(len(crawl_ja))), 20):
  print(crawl_ja[i])
  print(crawl_en[i])

### 保存
# クリーニング済みのテキストを保存
with open("jparacrawl/crawl_clean_ja.txt", "w") as file:
    for l in crawl_ja:
        _ = file.write(l+'\n')
with open("jparacrawl/crawl_clean_en.txt", "w") as file:
    for l in crawl_en:
        _ = file.write(l+'\n')


"""
すべてのコーパスの合体
"""

### 全部読み込み

# TED
with open('ted/ted_clean_ja.txt') as f:
    ted_clean_ja = f.readlines()
with open('ted/ted_clean_en.txt') as f:
    ted_clean_en = f.readlines()
print('TED:'+str(len(ted_clean_ja)))

# 青空文庫等
with open('aozora/aozora_clean_ja.txt') as f:
    aozora_clean_ja = f.readlines()
with open('aozora/aozora_clean_en.txt') as f:
    aozora_clean_en = f.readlines()
print('青空:'+str(len(aozora_clean_ja)))

# 京都
with open('kyoto/kyoto_clean_ja.txt') as f:
    kyoto_clean_ja = f.readlines()
with open('kyoto/kyoto_clean_en.txt') as f:
    kyoto_clean_en = f.readlines()
print('京都:'+str(len(kyoto_clean_ja)))

# 映画字幕
with open('subtitle/subtitle_clean_ja.txt') as f:
    subtitle_clean_ja = f.readlines()
with open('subtitle/subtitle_clean_en.txt') as f:
    subtitle_clean_en = f.readlines()
print('映画:'+str(len(subtitle_clean_ja)))

# 田中コーパス
with open('tanaka/tanaka_clean_ja.txt') as f:
    tanaka_clean_ja = f.readlines()
with open('tanaka/tanaka_clean_en.txt') as f:
    tanaka_clean_en = f.readlines()
print('田中:'+str(len(tanaka_clean_ja)))

# 法令
with open('law/law_clean_ja.txt') as f:
    law_clean_ja = f.readlines()
with open('law/law_clean_en.txt') as f:
    law_clean_en = f.readlines()
print('法令:'+str(len(law_clean_en)))

# JParaCrawl
with open('jparacrawl/crawl_clean_ja.txt') as f:
    crawl_clean_ja = f.readlines()
with open('jparacrawl/crawl_clean_en.txt') as f:
    crawl_clean_en = f.readlines()
print('JPara:'+str(len(crawl_clean_ja)))

print('計:' + str(len(ted_clean_ja)+len(aozora_clean_ja)+len(kyoto_clean_ja)+len(subtitle_clean_ja)+len(tanaka_clean_ja)+len(crawl_clean_ja)+len(law_clean_en)))

### 適当な分量に制限

# 京都
target_ix = random.sample(list(range(len(kyoto_clean_ja))), len(kyoto_clean_ja))  # 全部
kyoto_clean_ja = [kyoto_clean_ja[i] for i in target_ix]
kyoto_clean_en = [kyoto_clean_en[i] for i in target_ix]

# 映画
target_ix = random.sample(list(range(len(subtitle_clean_ja))), len(subtitle_clean_ja))  # 結全部
subtitle_clean_ja = [subtitle_clean_ja[i] for i in target_ix]
subtitle_clean_en = [subtitle_clean_en[i] for i in target_ix]

# JParaCrawl
target_ix = random.sample(list(range(len(crawl_clean_ja))), 200000)
crawl_clean_ja = [crawl_clean_ja[i] for i in target_ix]
crawl_clean_en = [crawl_clean_en[i] for i in target_ix]

### コーパスの合体とシャッフル
mixed_ja = ted_clean_ja + aozora_clean_ja + kyoto_clean_ja + subtitle_clean_ja + tanaka_clean_ja + crawl_clean_ja + law_clean_ja
mixed_en = ted_clean_en + aozora_clean_en + kyoto_clean_en + subtitle_clean_en + tanaka_clean_en + crawl_clean_en + law_clean_en

lines_ix = list(range(len(mixed_ja)))
random.shuffle(lines_ix)  # 代入しなくていい

mixed_ja = [mixed_ja[i] for i in lines_ix]
mixed_en = [mixed_en[i] for i in lines_ix]

print(len(mixed_ja))
print(len(mixed_en))

# 空の要素や要素数1の要素があるかどうかのチェック
empty_ix = [i for i in range(len(kyoto_clean_ja)) if len(kyoto_clean_ja[i]) == 1]
len(empty_ix)

# 対訳の確認(念のため)
for i in random.sample(list(range(len(mixed_ja))), 10):
  print(mixed_ja[i])
  print(mixed_en[i])

# 文の長さの分布確認
length_ja = [len(l) for l in mixed_ja]
length_en = [len(l) for l in mixed_en]

import matplotlib.pyplot as plt
import numpy as np

bins = np.arange(0, 425, 25).tolist()
plt.hist(length_ja, bins=bins, rwidth=0.8)
plt.show()

bins = np.arange(0, 850, 50).tolist()
plt.hist(length_en, bins=bins, rwidth=0.8)
plt.show()

# 分布を確認し、日本語は200文字、英文は400文字までに限定することにした
# (学習時のout of memory対策)

target_ix = [i for i in range(len(mixed_ja)) if len(mixed_ja[i]) <= 200 and len(mixed_en[i]) <= 400]
mixed_ja = [mixed_ja[i] for i in target_ix]
mixed_en = [mixed_en[i] for i in target_ix]

with open("mixed_1.3M_ja.txt", "w") as file:
    for l in mixed_ja:
        _ = file.write(l)
with open("mixed_1.3M_en.txt", "w") as file:
    for l in mixed_en:
        _ = file.write(l)

# 対訳の確認(念のため)
for i in random.sample(list(range(len(mixed_ja))), 20):
  print(mixed_ja[i])
  print(mixed_en[i])

### 保存
# クリーニング済みのテキストを保存
with open("mixed_1.3M_ja.txt", "w") as file:
    for l in mixed_ja:
        _ = file.write(l)
with open("mixed_1.3M_en.txt", "w") as file:
    for l in mixed_en:
        _ = file.write(l)

Jupyter Notebook/Jupyter LabがMacでちゃんと開くようにconfigを設定

Jupyter Labを使ってるのですが、起動しようとするとブラウザでちゃんと開いてくれなくて、変なHTMLファイルがテキストエディタで開くようになってしまいました。そのHTMLファイルに書かれてあるURLをブラウザに入れると使えるのですが。
これはconfigにデフォルトのブラウザを設定すればよいので、

~/.jupyter/jupyter_notebook_config.py

というファイルに、以下の一行を追加しました。(途中に書く場所があるけど面倒なので一番上に書いた)

NotebookApp.browser = '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome %s'

MacのAcrobatで見開きPDFを1ページごとに分割する

後でまた使うことがありそうなのでメモしておく。

  1. 見開きが複数ある場合は、まず、1見開き1ファイルに分割する
  2. 個々のファイルをAcrobatで開く
  3. ToolsのOrganize Pagesを開く
  4. Moreのメニューから「Set page Boxes」を開く
  5. インチで分かりにくければmmに変更し、プレビューの下に表示されている横幅の数値を2で割る
  6. Apply to 「CropBox」で、左半分を消したいときはLeftのところに、上記の2で割った数字を入れる
  7. OKを押して、保存する

ChatGPTにプログラミングを教わるとは、こういう感じです。

ChatGPTとのやり取りで一番楽しいのは、やっぱり、プログラミングを教えてもらうことですね。
今回は、とあるアニメーションの作り方を教えてもらいました。下にそのやりとりを掲載しておきますが、コードの中身は読み飛ばして、文章でのやり取りの部分と出力されたアニメーション動画を見るだけでも、ChatGPTにプログラミングを教わるとはどういうことなのか雰囲気がある程度分かるんじゃないでしょうか。
 
ポイントは、一発で何かが出来上がるのではなく、疑問をぶつけながら一緒にコードを完成させていく感じになるところですかね。今回の例だともはや、単にアニメーションの作成を依頼してるだけになってて、私はコードをほとんどみてませんが(笑)
 
ちなみに私はChatGPTに、
 

  • 言葉で指示した内容を実現するコードを書いてもらう
  • 読み解くのがめんどくさいコードを与えて意味や構造を説明してもらう
  • あるコードを実行してエラーが出た時に、コードとエラーメッセージを与えて解決方法を考えてもらう

 
というのを何度か試したのですが、人間に教わってるのとあまり変わらんというか、返事が一瞬で返ってくるぶん人間より有能かも知れません。やってて驚くのは、提案してくれるコードそのものよりも、「会話が成り立つこと」です。自然言語で頼んだことを、概ね理解してくれるのが凄い。
 
以下のやり取りでは、ChatGPT(GPT-4)に、セルラー・オートマトンの可視化のコードをつくってもらいました。最近Pythonでも似たようなことを試して、ライブラリのせいだと思いますが表示があんま綺麗じゃなかったので、今回はRのggplot2でやっています。
教えてもらったコードを実行して出力されたアニメーションも掲載しておきます。アニメーションは何回か修正してもらって、5回目で意図したとおりのものができました。
 

セルラー・オートマトンのルール30を、Rのggplot2で描画したいのですが、どのようなコードが考えられますか。
 

ChatGPT

セルラー・オートマトンのルール30を描画するために、R言語とggplot2パッケージを使用することができます。以下は、ルール30を実装し、結果をggplot2で描画する方法の一例です。
まず、必要なパッケージをインストールして読み込みます。

install.packages("ggplot2")
library(ggplot2)

次に、セルラー・オートマトンのルール30を実装する関数を定義します。

rule30 <- function(input) {
  n <- length(input)
  output <- numeric(n)
  for (i in 1:n) {
    left <- ifelse(i == 1, input[n], input[i - 1])
    center <- input[i]
    right <- ifelse(i == n, input[1], input[i + 1])
    
    output[i] <- as.integer(left != (center | right))
  }
  output
}
次に、初期状態を定義し、繰り返しルール30を適用して結果を格納します。
n_steps <- 100
initial_state <- numeric(n_steps)
initial_state[n_steps %/% 2] <- 1
states <- matrix(0, nrow = n_steps, ncol = n_steps)
states[1, ] <- initial_state

for (i in 2:n_steps) {
  states[i, ] <- rule30(states[(i - 1), ])
}
最後に、ggplot2を使って結果を描画します。
cells <- data.frame(
  x = rep(1:n_steps, times = n_steps),
  y = rep(1:n_steps, each = n_steps),
  state = as.factor(as.vector(states))
)

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank())

print(plot)これにより、セルラー・オートマトンのルール30がggplot2で描画されます。上記のコードは、100回のステップで実行されるルール30の例ですが、n_steps変数を変更することで、異なるステップ数で実行することができます。
 

出力結果


 

1秒に4ステップずつ追加して表示を更新していくような、アニメーションっぽいことはできますか?
 

ChatGPT

アニメーションのように表示を更新するために、gganimateパッケージを使用できます。まず、gganimateパッケージをインストールし、ggplot2とともに読み込みます。

install.packages("gganimate")
library(gganimate)
library(ggplot2)

gganimateを使用して1秒に4ステップずつ追加して表示を更新するには、以下のコードを実行します。

n_steps <- 100
n_steps_anim <- n_steps * 4
initial_state <- numeric(n_steps_anim)
initial_state[n_steps_anim %/% 2] <- 1
states <- matrix(0, nrow = n_steps_anim, ncol = n_steps_anim)
states[1, ] <- initial_state

for (i in 2:n_steps_anim) {
  states[i, ] <- rule30(states[(i - 1), ])
}

cells <- data.frame(
  x = rep(1:n_steps_anim, times = n_steps_anim),
  y = rep(1:n_steps_anim, each = n_steps_anim),
  state = as.factor(as.vector(states)),
  time = rep(1:n_steps_anim, each = n_steps_anim)
)

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time) +
  enter_fade() +
  exit_fade() +
  end_pause(25)

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600)
anim_save("rule30_animation.gif", anim)

これにより、rule30_animation.gifという名前のGIFアニメーションファイルが作成され、1秒に4ステップずつ表示が更新されます。n_steps変数を変更することで、異なるステップ数でアニメーションを実行できます。また、duration引数を変更することで、アニメーションの全体の長さを調整できます。
 

end_pauseって何の関数ですか?
 

ChatGPT

申し訳ありませんが、説明が誤っていました。end_pauseは関数ではなく、gganimateパッケージのanimate関数の引数です。end_pause引数は、アニメーションの最後に追加のフレーム数を指定して、アニメーションが一定時間止まるようにするために使用されます。これにより、アニメーションの最後に一時停止が発生し、アニメーション全体がより見やすくなります。
しかし、私の以前のコード例では、end_pause引数をanimate関数に渡す代わりに、誤ってend_pause()関数のように記述していました。正しいコード例は以下のようになります。

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600)

この例では、アニメーションの最後に25フレームの一時停止が追加されます。end_pauseの値を調整することで、この一時停止の長さを変更することができます。
 

要するにさっきのコードの

+
  end_pause(25)

を削除すればいいですか?
 

ChatGPT

はい、おっしゃる通りです。end_pause(25)を削除し、animate関数のend_pause引数に25を設定すれば、正しいコードになります。以下が修正済みのコード例です。

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time) +
  enter_fade() +
  exit_fade()

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600)
anim_save("rule30_animation.gif", anim)

この修正により、アニメーションの最後に25フレームの一時停止が追加されます。end_pause引数の値を調整することで、一時停止の長さを変更することができます。
 

以下のエラーが出ました。
> anim_save("rule30_animation.gif", anim)
Error: The animation object does not specify a save_animation method
 

ChatGPT

すみません。このエラーは、gganimateが指定されたファイル形式でアニメーションを保存するためのメソッドがないことを示しています。GIF形式でアニメーションを保存するには、gifskiとpngパッケージをインストールして、animate()関数のrenderer引数にgifski_renderer()を指定する必要があります。以下の手順で修正できます。
まず、gifskiとpngパッケージをインストールします。

install.packages("gifski")
install.packages("png")

そして、gifskiパッケージを読み込みます。

library(gifski)

animate()関数のrenderer引数にgifski_renderer()を指定してアニメーションを作成し、anim_save()関数でGIFファイルに保存します。

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = gifski_renderer())
anim_save("rule30_animation.gif", anim)

これで、エラーが解決され、アニメーションがGIF形式で保存されるはずです。rule30_animation.gifファイルが作成され、1秒に4ステップずつ表示が更新されます。
 

gifskiではなくavパッケージをつかってvideoファイルを作成するにはどうすればいいですか?
 

ChatGPT

avパッケージを使用してアニメーションをビデオファイルとして保存するには、まずavパッケージをインストールし、読み込みます。

install.packages("av")
library(av)

次に、animate()関数でアニメーションを作成し、renderer引数にav_renderer()を指定してビデオファイル(例えば、MP4形式)に保存します。

library(gganimate)
library(ggplot2)
library(av)

# ...(セルラー・オートマトンのルール30を描画するためのコード)

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time) +
  enter_fade() +
  exit_fade()

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = av_renderer("rule30_animation.mp4"))

これで、rule30_animation.mp4という名前のMP4ビデオファイルが作成されます。アニメーションは1秒に4ステップずつ表示が更新され、最後に25フレームの一時停止が追加されます。
もちろん、他のビデオ形式(例えば、WebMやAVI)にも対応しています。av_renderer()関数の引数に適切なファイル名を指定することで、異なる形式でビデオを保存できます。
 

出力結果


 

アニメーションはできました。しかしこのやり方だと、セルラーオートマトンが一行ずつ表示されているのですが、一般的には、一度表示された行は表示しっぱなしにして、だんたん画面がセルラーオートマトンで埋め尽くされていくようにします。そのように修正してもらえますか?あとついでにいうと、一般的には、一行目の真ん中が黒、他は白という初期状態からスタートします。
 

ChatGPT

ご指摘の通り、一般的にセルラーオートマトンは、新しい行が表示される度に既存の行がそのまま表示され続け、画面が徐々に埋め尽くされるように動作します。また、初期状態は真ん中のセルが黒で、他のセルが白です。この動作に修正するには、以下のようにコードを変更します。

n_steps <- 100
initial_state <- numeric(n_steps)
initial_state[n_steps %/% 2] <- 1
states <- matrix(0, nrow = n_steps, ncol = n_steps)
states[1, ] <- initial_state

for (i in 2:n_steps) {
  states[i, ] <- rule30(states[(i - 1), ])
}

cells <- data.frame(
  x = rep(1:n_steps, times = n_steps),
  y = rep(1:n_steps, each = n_steps),
  state = as.factor(as.vector(states)),
  time = rep(1:n_steps, each = n_steps)
)

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time)

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = av_renderer("rule30_animation.mp4"))

この修正により、アニメーションは一般的なセルラーオートマトンの動作に従い、新しい行が追加される度に既存の行が表示され続けます。また、初期状態は真ん中のセルが黒で、他のセルが白になります。このコードでアニメーションが正しく作成されるはずです。
 

出力結果


 

いや、これだとまだ、y=1の行、y=2の行、y=3の行…が順に1行ずつ表示される感じになっています。一度表示した行が消えてしまってます。また、そもそも、このセルラーオートマトンは、x軸がperiodに対応していて左から右に進んでいくものですよね?であれば、表示も、まずx=1の列(真ん中だけ黒くなっている)を表示し、次にその列は表示したままx=2の列を加え……というふうに左から右に広がっていくようにしたいです。
 

ChatGPT

申し訳ありません、理解しました。セルラーオートマトンが左から右に広がっていくように表示を修正するには、以下のようにコードを変更します。

n_steps <- 100
initial_state <- numeric(n_steps)
initial_state[n_steps %/% 2] <- 1
states <- matrix(0, nrow = n_steps, ncol = n_steps)
states[1, ] <- initial_state

for (i in 2:n_steps) {
  states[i, ] <- rule30(states[(i - 1), ])
}

cells <- data.frame(
  x = rep(1:n_steps, times = n_steps),
  y = rep(1:n_steps, each = n_steps),
  state = as.factor(as.vector(states)),
  time = rep(1:n_steps, each = n_steps)
)

cells$time <- pmin(cells$x, cells$time)

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  coord_flip() +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time)

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = av_renderer("rule30_animation.mp4"))

この修正により、アニメーションは左から右に広がっていくように表示されます。また、既存の列が表示されたまま、新しい列が追加されます。このコードでアニメーションが正しく作成されるはずです。
 

出力


 

いや、これもおかしいです。3つの意味でおかしいです。
1.セルラーオートマトンが下から上に展開するようになっています。つまり、period(generation, step)がy軸になってしまっています。
2.最初にx=1の列とy=1の行が表示され、次にそれらが消えて、x=2の列とy=2の行が表示され…というふうに、L字型に表示されるようになってしまっています。
3.依然として、既存の表示が残らず、各ステップの情報しか表示されないです。
 

ChatGPT

失礼しました。ご指摘の通り、私の修正は問題点を解決していませんでした。以下のコードで、お求めのアニメーションを正しく作成できるはずです。

n_steps <- 100
initial_state <- numeric(n_steps)
initial_state[n_steps %/% 2] <- 1
states <- matrix(0, nrow = n_steps, ncol = n_steps)
states[1, ] <- initial_state

for (i in 2:n_steps) {
  states[i, ] <- rule30(states[(i - 1), ])
}

cells <- expand.grid(x = 1:n_steps, y = 1:n_steps)
cells$state <- as.factor("white")
cells$time <- 1

for (t in 1:n_steps) {
  current_states <- data.frame(
    x = 1:n_steps,
    y = rep(t, n_steps),
    state = as.factor(ifelse(states[t, ] == 0, "white", "black")),
    time = t
  )
  cells <- rbind(cells, current_states)
}

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  coord_flip() +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_manual(frames = time)

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = av_renderer("rule30_animation.mp4"))

このコードでは、以下のようにアニメーションが正しく機能するはずです。
セルラーオートマトンが左から右に展開します(x軸がステップ数に対応)。
最初にx=1の列(真ん中のセルだけ黒)が表示され、次にその列が表示されたまま、x=2の列が追加され、以降は左から右に広がっていきます。
既存の表示が残り、各ステップの情報が重ねられて表示されます。
このコードで、期待されるセルラーオートマトンのアニメーションが作成されるはずです。
 

出力結果


 

惜しいです!セルラーオートマトン自体は左から右に展開しており、適切に修正されています。しかし依然として、各ステップの列だけが表示されるので、全ステップが表示から消えてしまっています。そもそも、前のステップが残るようにする上で、コードのどの部分を工夫されようとしてますか?修正意図を教えていただいたら、こっちで解決できるかも知れません。
 

ChatGPT

失礼しました。こちらで再度修正いたしました。今回は、transition_manual()を使わずに、transition_states()とshadow_mark()を使って各ステップで前のステップが残るようにしました。以下のコードが、期待されるセルラーオートマトンのアニメーションを作成できるはずです。

n_steps <- 100
initial_state <- numeric(n_steps)
initial_state[n_steps %/% 2] <- 1
states <- matrix(0, nrow = n_steps, ncol = n_steps)
states[1, ] <- initial_state

for (i in 2:n_steps) {
  states[i, ] <- rule30(states[(i - 1), ])
}

cells <- expand.grid(x = 1:n_steps, y = 1:n_steps)
cells$state <- as.factor("white")
cells$time <- 1

for (t in 1:n_steps) {
  current_states <- data.frame(
    x = 1:n_steps,
    y = rep(t, n_steps),
    state = as.factor(ifelse(states[t, ] == 0, "white", "black")),
    time = t
  )
  cells <- rbind(cells, current_states)
}

plot <- ggplot(cells, aes(x = x, y = y, fill = state)) +
  geom_tile(color = "black") +
  scale_fill_manual(values = c("white", "black")) +
  coord_flip() +
  theme_minimal() +
  theme(panel.grid = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank()) +
  transition_states(states = time, transition_length = 1, state_length = 1) +
  shadow_mark()

anim <- animate(plot, nframes = n_steps * 4, duration = n_steps, end_pause = 25, width = 600, height = 600, renderer = av_renderer("rule30_animation.mp4"))

このコードにより、以下の動作が期待できます。
セルラーオートマトンが左から右に展開する。
各ステップで前のステップが残る。
今回の修正で、お求めのアニメーションが作成されるはずです。
 

出力結果


 

これで完璧になりました。ありがとうございます。
 

ChatGPT

お手伝いできてうれしいです!正しくアニメーションが作成できたとのことで安心しました。
これからも何か質問やサポートが必要であれば、遠慮なくお聞きください。引き続きお手伝いできることがあれば喜んで対応いたします。

TransformerやAttentionの分かりにくい点についてのメモ

 ChatGPTの「GPT」はGenerative Pretrained Transformerの略であり、TransformerというのはGoogleが2017年に発表した『Attention is all you need』という論文で提案されたディープラーニングの画期的なアーキテクチャで、その論文のタイトル通り「Attention」という機構だけを使い倒している点が特徴的である。


 ……という話は色々なところで紹介されているのだが、私のような素人が読むと誤解してしまうような記述も少なくない。
 「仕組みを分かっていない人がいい加減な説明をしている」というよりは、「仕組みをよく知っている人が、素人の気持ちをあまり考えずに説明している」という感じで、悪気は全くないと思うし誤解するのは不勉強なこちらが悪いのだが、読むほうとしては困ることも多い。
 そこで、にわか機械学習ユーザである自分がall you need論文や巷の解説を読んでいて理解に詰まってしまった点について、簡単にメモしておきたい。ただし以下は走り書きなので、他の人が読んで分かりやすいように配慮はしていないし(笑)、間違ってるかも知れません。


 All you need論文はこちら:
[1706.03762] Attention Is All You Need

 入門的な解説はこのシリーズが詳しいかな:
Transformers Explained Visually (Part 1): Overview of Functionality | by Ketan Doshi | Towards Data Science


「アテンション機構のみ」で構成されてるわけではない

 これは中身を知っている人にとっては当たり前すぎて「揚げ足取り」のように思われそうだが、よく知らない人が「トランスフォーマーはアテンションという仕組みだけを使った画期的な手法である」と聞けば、やはり誤解したり混乱したりするのではないだろうか。
 「Attention is all you need」というのは、要するに「RNN(や畳込み)を使わない」という意味だ*1
 トランスフォーマーの内部では、順伝播型のニューラルネットがアテンション機構と同じぐらいたくさん出てきて、そこで色々学習しているので、全く「アテンションのみ」ではない。そもそもトランスフォーマーの場合、狭義のアテンション部分は学習パラメータを持ってないし。

「重要な情報に注目する仕組み」では何も説明できていない

 Attentionは「注意」「注目」といった意味であり、アテンション機構は「入力データのどの部分に注目すればよいかを判断している」と説明されることが多い。言い換えると、データの中で「どの部分が重要か」を判断し、情報に重み付けをしているということだ。
 しかし、「重要な情報に注目して重み付けする」のはただの順伝播型ニューラルネットでも同じなので、「アテンションは重要な情報に注目する仕組みです!」と言われても、何が凄いのかさっぱり分からない。
 実際の意味合いとしては、ふつうのニューラルネットにベクトルを投入して学習されるパラメータは「ベクトル内の各要素についての重み付け」を表しているのに対し、アテンションがやっているのは、「ベクトルがたくさんならんだ系列データ」を扱う際に、ベクトル間の関係を重み付けする仕事という感じだが、なんかうまい言い方を考える必要があると思う。

Attentionには系列「全体」の意味を捉えるという役割もある

 アテンション機構は、系列内のとある要素(ベクトル)から見て、ほかの要素のどれが重要なもの(強い関連性を持つ)であるかを判断している。しかし同時にそれを通じて、「系列全体の情報を取り込む=混ぜ合わせる」という役割を果たしてもいる。この両面性こそが重要なのだと考えるべきだろう。
 「アテンションは重要な情報に注目する仕組みです」と言われてしまうと、情報全体の特定の部分だけを活用する仕組みのように思ってしまう人が多いのではないだろうか。それはそれで、ある意味では正しい。しかしこの処理は、「重要度に重みをつけながら、系列全体の要素間関係を捉える」処理だとも言えて、「部分に向かう」と「全体に向かう」の両方向な役割を持っているのである。

「エンコーダ-デコーダ」モデルから説明しないと何も分からない

 上記2点については、Transformerが出てくる前の背景としてRNNベースの「エンコーダ-デコーダ」モデルが頭に入っていると誤解はしないのだが、その説明をすっ飛ばしてしまうと、たぶんあまり仕組みが理解できないと思う。だからもちろん、背景から説明されていることも多いのだが、そこがショートカットされている説明も散見される。


 もともと、RNNベースのエンコーダ-デコーダモデルにおいては、

  1. 1つ目の単語から順次処理して次に送っていく過程で、最初のほうの単語の情報が薄まってしまう(長期記憶が弱い)
  2. エンコーダからデコーダに渡される文脈ベクトルが固定次元なので表現力に乏しい
  3. 逐次処理なので計算に時間がかかる

などの問題があった。で、エンコーダとデコーダをアテンション機構*2でつなげることで大域的な文脈を失わずに処理するという工夫が提案され、1つ目と2つ目の問題に対処することが可能になった。そしてトランスフォーマーは、それをさらに押し進めてRNNブロック自体を排除してしまい、セルフアテンション等を用いて系列内の単語間関係を捉えるようになっている。また、RNNがいなくなったことで、計算も並列化できて速くなったとされる。

クロスアテンションのQに投入するのは「1つ前の単語」

 トランスフォーマーには2種類のアテンションが使われていて、一つが「self-attention」、もう一つは「encoder-decoder attention」と呼ばれている。後者は、source-target attentionとかcross-attentionとか呼ばれることもある。以下では、短くて呼びやすいので後者を「クロスアテンション」と呼ぶことにする。


 セルフアテンションは、入力又は出力の各系列の内部で単語間の関係を捉えるのに使われており、クロスアテンションは、入力の系列に含まれる単語と出力の系列に含まれる単語の関係を捉えるのに使われている。セルフアテンションはトランスフォーマーで一番重要とも言える機構であり、何をしているのかは分かりやすい。
 一方、後者のクロスアテンションの仕組みは、たとえば「This is a pen」→「これはペンです」という翻訳において「pen」と「ペン」が強く関係することを表現しているのだと説明される。それはまぁそうなのだが、このイメージを強く持ってしまうと、実際の処理をみていくときに混乱する。というのも、デコーダのクロスアテンション層でQ(クエリ)に投入されるのは、出力しようとしている単語の「1つ前」の単語の情報だからだ*3
 つまり、「ペン」を生成するときは、「は」を表すベクトル*4をクエリとして、「this」「is」「a」「pen」のそれぞれとの関連の強さを計算し、その強さで重み付けしながらバリューを足し合わせたものがアテンション情報として使われる*5。このアテンション情報が「ペン」の生成に使われることになるので、「ペン」との対応関係をみていると説明しても全く間違いではないのだろうが、「ペン」をクエリにするわけではないことに留意を促さないと、わけがわからなくなる。

何が入ってきて何が出ていくのか

 トランスフォーマーはけっこう部品が多いので、各部品の入口と出口で何が流れていくのかをいちいちハッキリさせておかないと、頭がこんがらがる。出入りするのがベクトルなのか行列なのかとか、次元は何なのかとかを毎度確認しないと、自分の理解が合ってるのかどうか不安になるのだが、そこが述べられていない説明を目にすることは多い。


 で、結局のところ、エンコーダにせよデコーダにせよ、トランスフォーマーブロックの中では常に「単語数×埋め込み次元数」の行列が流れていくと考えておけばよいと思う*6
 embedding層の前では各行が語彙数次元のone-hotベクトルになっていたり、最後の出力時には語彙数次元の確率分布ベクトルだったりするのだが、トランスフォーマーブロックの中では、「単語数×埋め込み次元数」の行列の各行にひたすら色々な変換が施されていく感じになっている(ただし各行が独立に処理されるという意味ではなく、セルフアテンションする時には行間=単語間で互いに影響し合うことになる)。
 逆にいうと、RNNの隠れ状態のような文脈ベクトル、つまり文意を1つのベクトルで表したものは生成されない*7
 あと、デコーダについては、仮に出力文のj番目の単語を出力しようとしている時には、

  • 入力されるのは、BOSトークンからj-1番目の単語までの意味ベクトル
  • 出力されるのは、1番目の単語からj番目の単語までの意味ベクトル

 というふうに「1個右にズレる」という点も、確認しておかないと頭がこんがらがるかもしれない。

KとVは同じもの(セルフアテンションではQも同じ)

 アテンション層の「Q」「K」「V」はクエリ、キー、バリューなのだが、データベース用語のイメージをあまり持ちすぎないほうがいいと思う。とくにセルフアテンション層ではQもKもVも同じ行列が投入されるので、「データベースにクエリを投げて、キーで探してバリューを取ってくる」みたいなイメージを持っていると、何をしてるのかわけが分からなくなる。
 クロスアテンション層では、KとVはどちらも「エンコーダの出力行列」であり、これはすなわち、各単語の意味ベクトル(を色々変換しまくったもの)を重ねたもので、全く同じものがKとVに投入される。ただし、アテンション層の入り口に線形層があって、ここでKとVには別々の変換が施されることになるので、結果的には別物になる。なお、Qはデコーダ側の入力単語ベクトルを重ねたものである。
 セルフアテンション層に至っては、Qも同じものが投入される。つまり、全く同じ行列を3つの入口に入れて、それぞれ「別の線形変換をした上で」アテンション処理が行われるという流れになっている。

逐次処理がなくなったわけではない

 推論時のデコーダ部分では、最初にBOSを入力すると1つめの単語が出力され、その1つ目の単語を入力すると2つ目の単語が出力されるというように、逐次的なループ処理になっている。並列的に処理する方法も考案されているのだが、ループさせたほうが精度は高いらしい。
 つまり、RNNを取り除いたと言っても、完全に逐次処理がなくなったわけではない(エンコーダと、学習時のデコーダは並列処理になっている)。

マルチヘッド処理では埋め込みベクトル自体が分割されてる

 トランスフォーマーのアテンションは「マルチヘッド・アテンション」と呼ばれ、系列を構成するベクトル間の関係を「複数の観点で」捉えるようになっている。
 イメージでいうと、「今日は大学で二次試験が行われていた」という文において、「二次試験」という語は文法的には「行う」との間に動詞-目的語という強い関係を持っているが、「二次試験」と「大学」の間にも、場面や文脈上の重要な関係がある。
 この「複数の観点」の学習は、ヘッドを複数用意するだけでいい感じにやってくれるんだろうか?と一瞬思ったのだが、もちろんそんなわけはなく、単語の埋め込みベクトル自体をチョン切って個々のヘッドに与えている。だから、ある意味もはや「別のデータ」を扱っているとも言える。
 たぶん、この「チョン切り」にうまく適応するように、埋め込み層の処理も学習されていくので、埋め込みベクトル自体が意味不明な数字の羅列ではなく、ある観点で重要な特徴次元が近い位置に集まるんだろう。面白い。

*1:論文では「RNNや畳込みを使わず、セルフアテンションに全面的に依拠してデータを処理する、初めての系列変換モデルである」と書かれている。

*2:ちなみに当初のアテンションは、Transformerのアテンションとは仕組みがけっこう違う。

*3:ちなみにRNNベースのエンコーダ・デコーダモデルでも、デコーダ出力の1つ前の隠れ状態と入力系列の関係がアテンションとして計算される

*4:セルフアテンションで自分より前の単語との関係が情報として織りこまれるので、もはや純粋に「は」を表すベクトルであるとは言えないかも知れないが。

*5:正確にいうと、デコーダにはから出力単語の1個前までのベクトルを重ねた行列が投入され、(BOSの次にくる)文頭単語から出力単語までのベクトルを重ねた行列が出てくるようになっており、ここではその最後の行だけに着目している。

*6:マルチヘッド化する時に一時的にちょん切られるが、すぐに結合される。

*7:細かいことを言うと、BERTで文章の区切り部分に入るトークン(CLS)のベクトルなんかは、文全体の意味を表しているとも言われるが。