ABEJA Tech Blog

中の人の興味のある情報を発信していきます

Common Crawlから作る大規模日本語コーパスとその前処理(Mixtral 8x7Bを語彙拡張継続事前学習 Part2)

ABEJAでデータサイエンティストをしている服部です。

ABEJAは国立研究開発法人新エネルギー・産業技術総合開発機構(以下「NEDO」)が公募した「ポスト5G情報通信システム基盤強化研究開発事業/ポスト5G情報通信システムの開発」に当社提案の「LLMの社会実装に向けた特化型モデルの元となる汎化的LLM」に採択されたことを受け、LLMの事前学習を実施しました。

その中でモデルの学習だけでなく、学習に欠かせない大規模日本語言語コーパスを作りました。データセットのサイズとしては、語彙拡張前のMixtral Tokenizerで約400Bほどのものです。 特にその中で大部分を占めるCommon Crawlをベースとしてデータセットを作った過程について解説します。

※このコーパス自体も公開できるよう調整中です。

データセットの概要

Common Crawlについて

Common Crawlは、インターネット上のウェブページを大規模にクロール(収集)して、そのデータを公開している非営利プロジェクトです。 このプロジェクトは、ウェブコンテンツのアーカイブを作成し、研究者、データサイエンティスト、開発者などが自由にアクセスして利用できるようにすることを目的としています。毎月あるいは四半期ごとに新たなクロールデータが追加され、過去分を含めるの相当な量のテキストが含まれています。

commoncrawl.org

オープンな言語コーパスとしては、このCommon Crawlを元としているものが非常に多いです。 有名なものとしては、RefinedWeb, RedPajama, mC4, CC100, OSCARなどがあります。 Common Crawlが使われるのは圧倒的なデータ量が理由かと思います。前処理未実施のデータで数百ペタバイトというレベルの量らしいです。

ただし、このデータをそのままLLMのコーパスとして使えるかでいうと、そうではなく、様々な前処理をしないとLLMの学習に使えるキレイなコーパスにはなりません。

warcとwet

Common Crawlのデータは主にWARC形式とWET形式があります。

  • WARC: ウェブクローリング中に収集されたデータの完全なアーカイブを含んだ形式。HTMLページ、CSS、JavaScriptなど、ウェブサイトの構成要素がほぼ含まれている。
  • WET: WARCファイル上のテキストデータのみを含んだもの。HTMLやその他のマークアップが除去され、プレーンテキストの形でウェブページのコンテンツが保存されている。

WET形式を用いるほうが簡単で軽いですが、WET形式のテキストにはWeb上の本文以外の情報、例えば広告やヘッダー部分、メニューなどを含んでいる割合が高く、学習コーパスとしてはノイズを含んでいます。また、テキスト情報しかないため、そこから本文のみを後で抽出するのが難しいです。 それに対し、WARCファイルはHTML全体を含んでいるので、自前での工夫やより精度の高いテキスト抽出ライブラリを使うことでWET形式よりもクリーンなテキストを得ることができます。 ただしダウンロードすべきデータ量が増えるのと、テキスト抽出自体の処理も増えるので、コストとしては高くなります。

LLM界隈のオープンデータセットでいうと、RefinedWebやSwallowコーパスでも品質重視のためWARC形式を使っています。 我々も今回WARCファイルを使いました。

データセット作成方針

今回Geniacプロジェクトに採択されてから、実際にGPUを使えるまでの期間は1ヶ月ちょっとしかなく、さらに処理を回せる環境が使える期間はもっと短い状況でした。 データセット作成期間としては短く、データセット作成に時間がかかりすぎると学習する時間が減ってしまうリスクがありました。 そのため期間内に終わらせることが最優先としてあります。 そのうえで、出来るだけ質 x 量を最大化出来るかが鍵になってきます。 (mc4などを使う選択肢もありましたが、最終的なモデルの精度面を考えると、自分たちで作るべきと判断しました。)

あとは出来るだけ効率的に処理を行うため - 処理速度が早い処理、データの削減率が高い処理を序盤に持ってくる - 重たい処理を出来るだけ後ろにする といった方針で進めました。

前処理の流れ

作成方針及び、前処理について公開しているRefinedWebSwallow コーパスを参考に以下の処理を行いました。

  1. 日本語の簡易判定、warcファイルからのテキスト抽出
  2. コンテンツのフィルタリング(ジャンル、言語判定、ドメイン、品詞割合等)
  3. 重複削除
  4. MLベースのフィルタリング
  5. 最終フィルタリング

以下で、それぞれの詳細について説明します。

1. 日本語の簡易判定、warcファイルからのテキスト抽出

日本語の簡易判定

最初に日本語の簡易判定を持ってきているのは、方針に記載した通り、処理速度が早くデータの削減率が高い処理を序盤に持ってきたかったためです。 具体的には、対象のデータがひらがなを含んでいるかどうかをUnicodeベースで判定しています。 後続の処理で行うもっと精度の良い言語判定もするのですが、最初に出来るだけデータ量を減らしたく、一番最初に行いました。 この方法だけだと、海外サイトでたまたま日本の商品を扱っているテキストとかがたくさん残ります。

Warcファイルからのテキスト抽出

Warcファイルからのテキスト抽出は、実質的にはHTMLファイルからのテキスト抽出と同じです。 まずwarcioというライブラリを使うと、WARCファイルをパースして、HTML形式のファイルを順に処理できます。

そしてHTML形式のテキスト抽出には、trafilaturaを使っています。

trafilatura.readthedocs.io

trafilaturaは、公式ドキュメントでも高い精度で効率的にテキストを抽出できることを謳ってます。

trafilatura.readthedocs.io

RefinedWebやSwallowコーパスでも同様にtrafilaturaを使っているのと、他のテキスト抽出ライブラリと比較してみて実際に精度が良いことを自分たちで確認できたため採用しました。 (HTMLのテキスト抽出はどれでもそんなに変わらないと思っていたのですが、精度が低いものだと、サイトのメニューテキストや広告文などを不要なものをより多く含んでおり、結構差があることを思い知らされました。)

また、今回行った工夫として、trafilaturaの中でinclude_formattingというオプションをTrueにしています。 これはHTMLのH2タグを##に変換するなど、HTMLの一部のフォーマット情報をMarkdown形式で出力してくれるものになります。 主にはHタグの#変換と、太字の**囲いでした。 基本的には一度章タイトルなどの情報を落としてしまうと後続処理で復元するのは難しいという考えのもと、このオプションをつけました。 実際、テキスト抽出する際にそのままやると、どこが章タイトルでどこが本文かが分からない例がいくつか出てきました。

また、これはpost-traningで考慮すべき話かもしれませんが、LLMのプロンプトでも意味段落ごとに#を使うことがあったり、より文章を体系的にLLMに学習してもらうためにも効果的なのではと考えました。

trafilatura.extract(content.decode("utf-8", errors="ignore"), include_formatting=True)

全体としての処理

全体としては以下の流れで処理しています。 (記載ない部分としては、URLの拡張子が画像のものを除いたりもしています。)

    def contains_hiragana(text: str) -> bool:
        return any("\u3040" <= char <= "\u309F" for char in text)

    # warc_url: common crawlからDLしてきたwarcファイルのurl
    resp = requests.get(warc_url, stream=True)
    contents = []
    urls = []
    dates = []
    for record in ArchiveIterator(resp.raw, arc2warc=True):
        content = record.content_stream().read(
        url = record.rec_headers.get_header("WARC-Target-URI")
         # 一部拡張子を事前に除く
         if url.endswith(".pdf") or url.endswith(".jpg") or url.endswith(".png") or url.endswith(".jpeg"):
            continue
         # Unicodeでのひらがな含んでいるか
         if contains_hiragana(content.decode("utf-8", errors="ignore")):
            # テキスト抽出
            extracted_text = trafilatura.extract(content.decode("utf-8", errors="ignore"), include_formatting=True)
            # 抽出部分でもひらがなを含んでいることを確認
            if extracted_text is not None and contains_hiragana(extracted_text):
                contents.append(extracted_text)
                urls.append(url)
                dates.append(record.rec_headers.get_header("WARC-Date")) 

コンテンツのフィルタリング(ジャンル、言語判定、ドメイン、品詞割合等)

ここでは複数の条件で対象データをフィルタリングしています。 具体的に行っているものは以下の処理です。 これらのフィルタを全て通ったものに絞っています。

  1. 日本語判定
  2. 性的・差別的・暴力的コンテンツ除去
  3. 文章における動詞比率でのフィルタ
  4. URLのドメインでのフィルタ
  5. 省略記号(...)のフィルタ

日本語判定

ここでは最初のUnicode判定よりも精度高い判定を行いました。 今回はfasttext-langdetectというfasttextのモデルをラッパーした言語判定ライブラリを用いています。

github.com

他にも言語判定のライブラリはありますが、fasttextでの判定が比較的精度が高いというのと、文章全体の言語判定であればfasttextで十分高い精度が出ていると目検で見て、問題無しとしました。 最近だと、linguaなどのライブラリも出ており、より精度を高めたい場合はこれも検討して良いかもしれません。

GitHub - pemistahl/lingua-py: The most accurate natural language detection library for Python, suitable for short text and mixed-language text

性的・差別的・暴力的コンテンツ除去

Common Crawl上のデータにはこういったコンテンツも多く含んでおります。

今回は、文章をテキストを形態素解析で分割→NGワードを一定以上含んでいると、フィルタ対象として除去する形としました。 データ量的に、形態素解析が重めではあるのですが、後続の動詞比率の算出にも活用するのと、MeCabであれば比較的高速であるため、大きなボトルネックにはなりませんでした。

形態素解析せずにNGワードの部分一致でやると、誤検出がそれなりにあったため、精度的にはやはり形態素解析したほうがよいと考えています。

具体的には2種類以上のワードを含んでいる場合に除去しました。 (1件だと異なる意味合いで引っかかるケースが多かったため)

NGワードとしては、llm-jpさんのリポジトリに入っているワードを活用させていただきました。(ありがとうございます)

github.com

文章における動詞比率でのフィルタ

実際のデータを見ると、ほとんどが名詞であるページが散見されました。 ショッピングサイトの商品ページのようなものもあれば、何かの索引ページだったり、解読できないものまで。

一般的な文章であれば一定比率は動詞が含まれるはずという仮定の下、MeCabの形態素解析上で動詞だった単語の割合からフィルタをしました。

URLのドメインでのフィルタ

一部の頻出ドメイン(.com, .jp, .netなど)のデータに絞りました。 ここで絞ることで、頻度が低いドメインだけど品質がよい文章も削られてしまうデメリットは有りましたが、マイナードメインでは学習データには不向きと思われるデータの比率が高かったため、この処理を適用しました。 RefinedWebなど他のコーパスでもドメインでのフィルタは行っているようです。

省略記号(...)のフィルタ

クローリングデータ特有のものとして、そのページに本文が載っていなくて、冒頭の文章だけが載っているケースがあります。 例えばブログで、「こんにちは。今日はお花見に行ってきました。場所は…」みたいな記事の冒頭文がたくさん載っている記事一覧のページだったり、まとめ系サイトだといろんなページへのリンクとそのリンク先のテキスト冒頭だけ載っているといったケースが多々あります。 ABEJAのテックブログですとこういった部分です。

そのページ自体が本文とは異なるものであり、一部しか含まれていないため、こういった文章は取り除くことにしました。

省略記号と言っていますが、厳密には三点リーダ(…)とピリオド3つ以上連続(...)を対象にしています。

通常の文章内でも使いうる記号ではあるので、省略記号が3つ以上かつ10%以上の行で省略記号で終わっている明らかに多いもののみフィルタをしました。

重複削除

こちらもLLMの学習データとしては重要な処理です。 同じ文章を何度も学習すると精度が落ちるという研究もあり、ほとんどの学習コーパスでは重複削除処理をしています。 重複と言っても完全一致のものもあれば、部分一致や類似度が高いものも含みます。 重複削除のアルゴリズムとしては、MinHashやsuffix arrayなどが有名です。

他の処理と異なるところが、処理が一つ一つの文章で独立に出来るものではなく、相互の関係を測る必要があるところです。 すなわち並列に処理すればいいというわけではないです。

今回データ量的にもかなり多く、自前実装では時間がかかりそうだったのもあり、text-dedupというOSSを利用してMinHashによる重複削除を行いました。 GCPのdataproc(Spark)での処理にも対応しており、大規模なデータであっても対応可能で助かりました。 ただSpark特有のパラメータ調整には時間を取られました...

github.com

実際には、事前にURLの重複削除(一番新しい日付のものを残す)をしてデータ量を減らした後に、MinHashを行いました。

MLベースのフィルタリング

ここの処理は独自部分かと思います。

ここまでのフィルタリングで、それなりにデータは綺麗になりました。 ただ、ここまでのフィルタリングは文章全体を使う/使わないといったものであり、1つの文章内に不要な部分があるケースを考慮していません。

実際に、ここまでのフィルタリングでは、本文としては使えそうな日本語だけど、以下のような文が混じっていることがあります。 trafilaturaによる抽出で事前に除けるケースもありますが、それでも結構散見されます。

  • 広告の文章
  • アーカイブの年月文
    • 例: 2023/12(3), 2023/11(10), 2023/10(4), ....
  • 別記事へのリンク(続きを読むで終わったり、【関連記事】から始まるような文)
  • メニューの文字たち
    • 例: 概要、事業内容、よくある質問、アクセス等
  • その他サイト特有の頻出文
    • 例: トラックバック、コメント、スポンサーリンク等

実際どこまでこれらを取り除くべきか?は難しいところですが、Webサイト自体の構成すべてを学習してほしいわけではなく、不要なものは多くあり出来るだけ取り除こうと考えました。

そこで、不要な行かどうか?を予測する機械学習モデルを作り、不要と予測したスコアが高い行を取り除くようにしました。

具体的には以下の手順で行いました。

  1. 実際のクローリングデータに対して、複数名のLLM開発メンバーでLLMの学習データとして取り除くべきか?のアノテーションを実施(行単位のアノテーションで数万件実施)
  2. 1でアノテーションした行単位の文に対して特徴量エンジニアリングを行い、LightGBMで学習
  3. 全データの全行に対して不要度合いを予測。不要度合いが高いスコアの行を除く、また文章全体として不要度合いが高いケースは文章全体を除く

1について、事前にデータを開発メンバーで見ながらある程度意識合わせした上でアノテーションしましたが、それでもブレはでました。ただ、完全にすべて取り除くと言うよりは全員が不要と考えるようなものが除けるだけで効果があるはずであり、よしとしました。 2について、本来文章に対しての学習・予測のため、BERT系のモデルのほうが精度が高いのは明らかでしたが、データの総量が多すぎて、BERTだと複数のGPUを使っても推論時間が長くなりすぎるため、断念しました。 ちなみに、特徴量としては以下のようなものを使っています。 学習データの文章量が十分になく過学習の可能性と、推論時の速度面から、単語自体を用いた特徴量(TFIDFやWord2Vec等)は用いない方針としました。

  • 品詞のカウント・割合
  • 文字、句読点、記号、省略記号(…)、数字の数
  • ひらがな・英数字の割合
  • 上記の中で重要度の高い特徴量について、
    • 前後の行の情報(shift特徴量)
    • 文章全体での集約特徴量(平均、最大)
  • 日付関係の文字列、URL文字列、不要キーワードについてのカウント

アノテーションデータに対しての精度的には、Cross ValidationでROC-AUCで0.87、F値で0.78くらいでした。

また最終的に手順3において除くかどうかについては、以下の処理を実施しました。

  • 以下の場合は文章全体を除く
    • 文章全体の不要度合いの予測スコアの平均が一定以上
    • 文章全体の不要度合いの予測スコアの25percentile, 50percentileが一定以上
  • 行単位で予測スコアの平均が一定以上の場合対象行を除く

後者の行単位でのフィルタの閾値を低くすると、偽陽性により本来必要な文を除いてしまうリスクがあったため、それを避けるべく少し高めに設定しています。(precision重視) ただし、結果としてこれも除きたかったという文章が含まれるケースは有りました。

最終フィルタリング

これは、上記前処理をした上で追加でしたほうがよいと思ったものを最後にしたものです。 処理速度的にももっと前段ですべきものもあったかなと思いますが、どうしても処理のコスト的に最初からやり直すのは難しく、最後に追加しました。 内容としては、以下を除きました。

  • 以下のデータを除く
    • 文章の平均長が15以下のもの
    • 文章が100文字以下のもの
  • 太字の囲い文字(**)の除去
  • URLの除去

Swallowコーパスに比べると、少し条件を緩いですが、ここまでの前処理の結果この条件でも品質のいい文章が多かったので、この条件としました。

実行環境

これらの処理について、データとしては大量にあるため、1台のマシンで何とかなるレベルではありません。 また今回時間的にもかなり限られていたため、高速化する必要がありました。

そこで今回はGCP上で以下の形で行っています。

|1. 日本語の簡易判定、warcファイルからのテキスト抽出| Pub/Sub + Cloud Run Jobs| |2. コンテンツのフィルタリング(ジャンル、言語判定、ドメイン、品詞割合等)| Pub/Sub + Cloud Run Jobs| |3. 重複削除| GCP Dataproc | |4. MLベースのフィルタリング| Pub/Sub + Cloud Run Jobs| |5. 最終フィルタリング| Pub/Sub + Cloud Run Jobs|

Pub/Sub + Cloud Run Jobs

データはGCSにある前提で以下の形で処理しています。

  1. それぞれ分割して処理する単位を決める(1の処理だとwarcファイル単位、2以降はバッチサイズを指定して分割)
  2. Pub/Subに、分割単位で対象とするGCSのパスをPublish
  3. Cloud Run Jobsで、Pub/SubからメッセージをPull、それぞれ指定の処理を実施して、GCSにアップロード

Cloud Run Jobsでは、一つのジョブあたりのボリュームは小さめでインスタンスのスペックも比較的小さいものにしています。 理由として、多くの処理でGCSからのダウンロード・アップロードといった通信部分がボトルネックになるため、大きなマシンで台数減らすより小さなマシンを大量に動かす方針で実施しました。

実際には途中で何らかエラーになるケースもあったため、正しく処理できたデータのみを除いて再度Pub/SubにPublishしたりもしていました。

ちなみに一部の処理では、数万個のジョブを同時に走らせるほどの大規模な処理でした....おかげで処理時間はかなり短縮すること出来ました。 このパイプラインを作っていなかったら間に合わなかったと思います。

今後の課題

今回、様々な前処理を行いましたが、当然これが最適解というわけではなく、もっと改善の余地があると思っています。 以下、私が感じている課題感です。

何をするとLLMにとって良いデータセットになるのか、正解が分からない

一番大きな問題はこれかなと思っています。 一般的な機械学習では、データセットの前処理を変えたときの精度を比較することで、特定の前処理の有効性を判断しやすいです。 ただ、LLMの場合、一回あたりの学習コストが馬鹿にならなくて、この比較がし辛いです。 一回あたりのデータセットの量を減らす、小さなモデルで試す等の方法も考えられますが、それでもコストがかかるのと、それでどこまで有効と言えるのかという問題があります。 また同時にLLMのベンチマーク指標もブレがあるものが大きく、細かな改善の有効性が判断しづらいというのもあります。

唯一あるとしたら、現時点のCommon Crawlのデータでは、まだまだ目検で直感的にも質が悪いと言えるケースは存在します。 「続きを読む」で途切れているものや、広告の文書が挟まっているもの等は出来るだけ削ったほうがいいというのは、明確な根拠があるわけではないですが、正しい気がします。 そして、現状でもこれらを完全除去出来ているわけではないので、まだ改善の余地があるのはわかります。

データセット周りの論文での知見が増えたり、同一条件のモデルに学習させるデータセットの前処理を競うコンペの開催などで、より世の中的に知見が貯まるといいなと思います。

改行の多い文章

元がブログなどの文章だと、やたらと改行されているものが結構あります。 句読点や助詞毎に改行されているケースなどですね。 以下のようなイメージです。

昨日
なんと遊園地に
いってきましたー

すごく
よかった

こういった文章が多いと、やたらと改行を出力する可能性があります。 すべての改行を消すことも出来るのですが、そうすると本来別の文同士を繋げてしまうことにもなり得ます。

別の方法として、文境界を判別するライブラリを用いる方法もあります。

https://github.com/megagonlabs/bunkai

今回こちらも検証してみたのですが、 - 判断を誤るケースもある(章タイトル等、名詞の羅列など、通常の文章と違うところなど) - 処理時間が長い(データセットのサイズ的に厳しい) といったところで今回は断念しました。

ただ、改行周りについてはまだ改善の余地があると思っています。

表形式

実際のデータでは表形式のデータを含む場合も多いです。

例えば、今のデータセットでは下記のような形になっています。

|住所
|東京都xxx
|交通手段
|山手線xx駅東口からxxx
|定休日
|金曜日

何となくこれでも読み解けますが、表のどこまでが同じ行か分かりにくい、LLMにとってこの形式が良いのか悪いのか分からない等の課題があります。 個人的には下記のような形式のほうがマシだとは思っています。

|住所|東京都xxx|
|交通手段|山手線xx駅東口からxxx|
|定休日|金曜日|

これを作るにはHTMLからテキスト抽出時点で対応すべきであり、それなりに対応が必要と思われます。

不要文の削除

今回、MLベースのフィルタリングにより一定程度フィルタリングは出来ました。 ただ、やはり課題は残ります。

  • 一定程度、削ったほうが良さそうなものが残っている
    • 例: 【合わせて読みたい記事】みたいな文、広告文等
  • 削るべきか悩ましいもの
    • 例: ブログへのコメント文章、画像を前提とした文章等

前述の通り、どういった文章を入れるかどうかの知見があるほど改善されたり、フィルタリングにTransformerを用いるなどで改善の余地はありそうです。

ジャンルの偏り

クロールされたデータには当然のようにWeb上に存在するサイトの分布にも影響を受けるため、ジャンルの偏りがあります。 例えば、ブログでの日常的な日記や、アフィリエイトの多いジャンルの記事(投資、美容・健康など)は比率が高いです。 一方で、科学的な専門性のある情報は少ないです。

実際にLLMに入れるデータとして、このままの比率としていいのかは難しいところで、何らか類似度でクラスタリングして多いカテゴリは減らす等の対応があったほうがよいかもしれません。 重複削除のところで重複の基準を緩めることで類似文章が減って、ジャンルの偏りも減らせる可能性もあるかもしれません。

重複削除周り全般

今回時間も限られていたこともあり、重複削除周りは十分に検討が出来ませんでした。 MinHashをベースとしましたが、

  • MinHashとしてもパラメータをどうするのが最適かが分からない
  • MinHash以外の手法(Suffix arrayなど)と併用したほうがよかったかもしれない

といったところがあります。

ただ、ここもどう設定するとLLMにとって最適なのかはなかなか難しいところだと思います...

最後に

今回限られた時間の中で、工夫しながら前処理を多い、データセットを作りました。 個人的には、質がよい大規模日本語コーパスを作れたと思います。

ただし、完璧なコーパスは存在せず、まだまだ改善の余地はあると思っています。 一つのコーパスを作るためのコストもばかにならないため、こうした情報をオープンにしていき、次誰かが作るデータセットはより高品質になるよう、参考になればと思っています。

日本語のデータはまだまだ英語に比べると少ないため、日本語のLLM界隈の発展のためにも寄与できたらと思っています。

また、大規模な事前学習及びそこに必要なデータセット作成を実施する機会を与えてくださったすべての関係者の皆様に感謝申し上げます。 ここで記載したデータセット作成・前処理だけでなく、モデル開発におけるインフラ・モデル理解・評価等々多くの学びがありました。 こういった学びを可能な限り公開し、日本の生成AIコミュニティへ貢献できたらと思います。

この成果は、NEDOの助成事業(JPNP20017)の結果得られたものです。