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

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

DataParallelでの複数GPUの並列化が上手くいかない(PyTorch)

単なる作業経過のメモです。
AWSで、gクラスのインスタンスのvCPU数上限緩和を申請したら通りまして、複数GPUのインスタンスが使えるようになりました。
そこでGPU4枚のインスタンスを立てて、以下のような情報を参考に、先日構築したTransformer翻訳機にとりあえずDataParallelのほうを適用してみたのですが、なかなかうまくいかず苦労しています。


DataParallel — PyTorch 2.0 documentation
Distributed Data Parallel — PyTorch master documentation
Pytorch高速化 (1)Multi-GPU学習を試す - arutema47's blog
pytorch DistributedDataParallel 事始め - Qiita
DDPによる学習時間の高速化を確認してみる | FORXAI | コニカミノルタ


最初、一番苦労したのは、DataParallelがバッチを自動的に4分割する際に、マスクの形状が乱れることです。Transformerのマスクの制御は、本質的にそんなに難しいわけではないはずなのですが、いろいろミスが出てエラーに繋がります。
とりあえず、Transformer本体の関数に対して外でつくったマスクを与えるのではなく、なるべく内部で生成するようにしたら、エラーは出なくなりました。


ところが学習をさせてみると、どうも1エポック目の1バッチ目から、forward時にすべての出力(テンソルの中の値)がnanになってしまう問題が発生しており、とりあえずいま作業時間がないので中断して放置してます。
もしかしたら、最初からDistributedDataParallelのほうを試したほうがいいのかもしれない。そっちのほうが、適用するために書く部分は多いんですが、PyTorch公式でもそっちがおすすめされていて、速度も速いらしい。


【追記】DistributedDataParallelを使ったらうまく行きました。DDPのほうは、Jupyter Lab等の対話環境から使うのには向いておらず、学習用のコードをスクリプト化して直接実行することが前提になるので、もともと対話環境でコードを書いていた場合はそれを再構成するのがめんどくさい……と感じたのですが、実際やってみたら大して面倒ではなかったし、動作も安定していて何よりです。

英日翻訳の混合コーパスとJParaCrawlで機械翻訳の品質を比べてみた

※コーパスを「自作」したとは言えないのでタイトルから「自作」を取りました
 
先日、小型Transformerで英→日の機械翻訳をやらせてみた(先日のエントリ)のですが、その際にコーパスは、いくつかの無償配布コーパスを処理して独自に再編成したもの(合計約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つでも含まれるとバッチがデカくなりすぎてメモリを圧迫し、そのせいで全体としてバッチサイズを大きくできなくなってしまうため。)


先日の別のエントリで述べたように、JParaCrawlというのがくせ者で、ウェブから収集した2500万もの例文が収録されていて規模はデカいので魅力的なのですが、中身をみると品質にばらつきがかなりあります。質の低い機械翻訳で作られているサイトの対訳を引っ張ってきていたり、そもそも英文と日本文の意味が対応してなかったりもします(全く対応してないものもあれば、最初は対応してて途中から別の話になっているようなものもある)。


だから私は件数は我慢して、上述のように128万件の混合コーパスのうち20万件だけをJParaCrawlにしてるのですが、これを全部、クリーニングなしのJParaCrawlにしたらどうなるのかを試してみました。ただし今の環境だと2500万件で実行するのはつらいので、件数を揃えて、JParaCrawlの対訳コーパスからランダムに128万件を選んだらどうなるのかを試してみました(なのでこれがJparaCrawlの「本気の実力」ではない点に注意)。


以下が、混合コーパスとJParaCrawlの訳の比較ですが、どちらにも誤訳が含まれていて、ジョブズ・トランプ・アインシュタインの例文についてはどっちもどっちですが、その他は混合コーパスのほうがいいと思えます。ポテンシャルから言えば、件数が多いことのメリットはあると思うので、JParaCrawlを根性でクリーニングしてまともな対訳だけを選んで、たとえばJPara400万件とその他100万件みたいな混合コーパスにしたりするのが理想的かもしれません。

Your time is limited, so don’t waste it living someone else’s life.
【混合】時間は限られているので、他人の人生を浪費しないでください。
【JPara】あなたの時間は限られているので、他の誰かの人生を生きることを無駄にしないでください。
(拙訳:君たちの時間は限られている。他人の人生を生きるようなことをしてそれを無駄にするな。/スティーブ・ジョブズ)
 
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人の小さな子供たちが、いつか、自分の肌の色ではなく、その性格の特徴によって判断されない国家に住む夢を持っています。
【JPara】私は4人の小さな子供が1日、彼らが彼らの肌の色によって判断されない国に住んでいることを夢見ていますが、彼らのキャラクターの内容によって。
(拙訳:私には夢がある。私の4人の子供たちがいつの日か、肌の色ではなく彼らの人格によって判断される国に暮らせるようになるという夢が。/キング牧師)
 
You are fake news!
【混合】偽のニュースです!
【JPara】あなたは偽のニュースです!
(拙訳:[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.
【混合】夢想家だって言うかもしれないけど、私だけじゃない。いつか、ぼくらと合流してくれるといいんだけど。そして世界は、ひとつのものになる。
【JPara】夢の話ですが、私は唯一のものではありません。
(拙訳:夢想家だと君は言うかもしれないけれど、僕は一人じゃない。いつか君も一緒になれれば。そして世界は一つになる。/ジョン・レノン)
 
The madman is not the man who has lost his reason. The madman is the man who has lost everything except his reason.
【混合】狂人は理性を失った人間ではない。狂人は、理性以外はすべてを失った男だ。
【JPara】狂った人は彼の理由を失った男ではありません。
(安西徹雄訳:狂人とは理性を失った人のことではない。狂人とは、理性以外のあらゆる物を失った人のことである。/チェスタトン)
 
The safest general characterization of the European philosophical tradition is that it consists of a series of footnotes to Plato.
【混合】ヨーロッパ哲学伝統の最も安全な一般的な特徴は、それがプラトンへの一連の脚注から構成されていることです。
【JPara】ヨーロッパ哲学の伝統の最も安全な特徴は、プラトへの一連の足ノートのシリーズからなることです。
(拙訳:ヨーロッパ哲学の伝統について間違いなく言えるのは、その全てが、プラトン哲学へのひと続きの注釈に過ぎないということである。/ホワイトヘッド)
 
Violence sometimes may have cleared away obstructions quickly, but it never has proved itself creative.
【混合】暴力は時々すぐに妨害を片付けるかもしれませんが、それは創造的に証明されたことはありません。
【JPara】暴力は時々すぐに閉塞をクリアしたかもしれませんが、それはそれ自体を証明したことはありません。
(拙訳:暴力が、問題を手っ取り早く片付けるのに役立つことはある。しかし、暴力それ自身が創造的であったことは一度もない。/アインシュタイン)


ちなみに下記のブログ記事では、JParaCrawlのデータを「全部」つかって学習させたようで、両側6層のTransformerなので私がやったやつより深いモデルですが、翻訳精度が思ったほど高いとは言えない。推論のサンプルが少ないのでわかりませんが、上記の私が使った「混合コーパス」による翻訳よりも、コーパス規模は20倍(しかもモデルの層の深さも2倍)なのに、精度は似たようなもんです。??になっているところは、コーパスの問題というより、トークナイザの未知語の処理方法の問題のような気がします。
SageMakerでJParaCrawlのコーパスを使って翻訳モデルを作成する

Hello, world!
【混合】へえ、こんにちは、世界。
【JPara全部】⁇ 、世界!
 
It is fine today.
【混合】今日は晴れだ。
【JPara全部】今日は大丈夫です。
 
The average viewership in the Kanto region was gauged over an hour from 3 p.m.
【混合】関東地方における平均的な視野が午後3時より1時間以上発足された。
【JPara全部】九州地方の平均視聴率は ⁇ 3時から1時間にわたって ⁇ されました。
 
We are in touch with Hilaree’s family and supporting search and rescue efforts in every way we can.
【混合】私たちは、ヒレーの家族と連絡を取り合い、できる限り検索と救助活動を支援しています。
【JPara全部】ヒラリーファミリに連絡を取り、あらゆる方法で検索と救 ⁇ 活動をサポートしています。


やっぱり、JParaCrawlはそのまま使うのではなく、かなり気合をいれてクリーニングしたほうが良さそうです。

AWSのディープラーニング用インスタンスを検討する

Google Colabの環境は気に入ってるのですが、操作しないと90分でランタイムが切断されるルールや、Pro+に入っても24時間以上は回せないルール、さらには実行中にGoogle Driveに書き込めなくなったりする不具合などもあって、長時間の学習ではストレスが溜まります。


その点、AWS(EC2)だともう少し安心して回しっぱなしにできるはずなので、移行を考えることにしました。


使えるGPUの種類が分かりにくかったんですが、下記のページによると、
推奨 GPU インスタンス - Deep Learning AMI


P3:NVIDIA Tesla V100 GPU が最大 8 個搭載されます。
P4:NVIDIA Tesla A100 GPU が最大 8 個搭載されます。
G3:NVIDIA Tesla M60 GPU が最大 4 個搭載されます。
G4:NVIDIA T4 GPU が最大 4 個搭載されます。
G5:NVIDIA A10G GPU が最大 8 個搭載されます。


ということらしい。
GPUのスペックはこのへんの比較サイトにモデル名を入れると比較できるが、たぶんA100>V100>A10G>T4>M60という感じか。
CPUとGPUの仕様性能比較 | TopCPU.net
ビデオカードを比較する


気になるのが、バッチサイズに直接影響するGPUメモリの容量で、とくにTransformerはとてもメモリを食う。Colabで小型Transformerに英日翻訳を学習させたとき、メモリ40GBのA100*1を使ってたのだが、これでも少ないと感じてしまった。
ところで、Colabでメモリ40GBのA100が使えることを考えると、いくら長時間稼働に関してAWSが有利だとはいっても、あまりスペックを落として使うぐらいならColabで高速に済ませてしまったほうがいいような気はしてくる。ただAWSではGPUが複数登載されたものがあり、並列化することでさらに速くできるので、安いやつを並列で使うと総合的にみてColabよりお得だったりするかもしれない。


A100が使えるP4インスタンスは現状「p4d.24xlarge」しか選択肢がなく(リージョンによるらしいが)、これはA100が8個もついてて、1時間45ドルもする。これは気軽には使えないが、もう少しスペックの低いインスタンスで複数GPUの並列処理のコードを試してから、本気の処理をするときだけこのインスタンスに乗せてもいいのかもしれない。
V100が使えるP3インスタンスは、1個、4個、8個のものがあり、8個つきの「p3dn.24xlarge」は1時間43ドルもするが、1個だけの「p3.2xlarge」だと4ドル、4個つきの「p3.8xlarge」だと17ドルとなる。
A10Gが使えるG5インスタンスは、8個つきの「g5.48xlarge」が1時間24ドルで、4個つきの「g5.24xlarge」が12ドル、1個つきの「g5.16xlarge」だと6ドルに成る。
T4が使えるG4インスタンスは、1個つきのうち一番安い「g4ad.xlarge」が1時間0.5ドルで、4個つきの「g4ad.16xlarge」や「g4dn.12xlarge」が5ドル、8個つきの「g4dn.metal」が11ドルになっている。


これらを総合的に考えて、とりあえず、T4を4個つけた「g4dn.12xlarge」で複数GPUを並列で回すのを試してみて、うまく回りそうだったら最強スペックの「p4d.24xlarge」に移植してみようかなと思った。まだ考え中ですが。

*1:A100には80GBの製品もあり、AWSではこれが使えるのかもしれない。あとで確認する。

小型の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'