Span Corruption — T5方式の事前学習を理論と実装で完全理解する

「The cat sat on the ___ and looked out the window」— BERTのマスク言語モデル(MLM)は、このように1つのトークンを隠して穴埋めさせる方式で事前学習を行います。しかし、もし「sat on the mat and looked」のように連続する複数のトークンをまとめて1つの穴にしたら、モデルはどのような能力を獲得するでしょうか?

実は、この「まとめて隠してまとめて復元する」という発想こそが、T5(Text-to-Text Transfer Transformer)の事前学習タスクであるSpan Corruptionの核心です。BERTのMLMがトークンを1つずつ独立にマスクするのに対し、Span Corruptionは連続するトークンの塊(スパン)を1つの特殊トークンで置き換え、デコーダにそのスパンの中身を復元させます。

この違いは単なる技術的バリエーションにとどまりません。Span Corruptionの設計には、以下のような深い動機と応用があります。

  • 計算効率の大幅な改善: 15%のトークンをマスクする場合、MLMでは15%のマスク位置がそのまま残りますが、Span Corruptionでは複数のマスクトークンが1つのSentinelにまとめられるため、デコーダが処理するターゲット系列が大幅に短くなります
  • 長距離依存関係の学習: 連続する複数トークンを一度に予測する必要があるため、モデルは局所的な共起パターンだけでなく、より広い文脈に基づく生成能力を獲得します
  • Encoder-Decoderアーキテクチャとの自然な統合: 入力(破損テキスト)→ 出力(復元テキスト)というテキスト変換の枠組みが、T5のText-to-Textフレームワークとシームレスに結合します
  • SpanBERTやUL2など派生手法の理解: Span Corruptionの数理を理解することで、スパンベースの事前学習手法全体の設計原理が見通せるようになります

本記事では、Span Corruptionの数学的定式化からPythonでの実装まで、この重要な事前学習手法を完全に解説します。

Span Corruptionの仕組み 連続スパンをsentinelに置換しターゲットで復元

この図がSpan Corruptionの全体像です。元のテキストから連続するスパン(「sat on」「mat and looked out」)を選び、それぞれを <X><Y> という固有のSentinelトークンに置き換えて入力を作ります。デコーダは、各Sentinelに続けてその中身を左から右へ生成し、末尾に終端記号 </s> を出力します。入力からスパンの実体が消え、ターゲットに圧縮されている点が、これから順に解き明かしていく核心です。

本記事の内容

  • MLMとSpan Corruptionの構造的な違い
  • Span Corruptionの数学的定式化と目的関数
  • Sentinelトークンの仕組みと役割
  • スパン長の幾何分布による確率的マスク生成
  • Corruption rateの設計とその影響
  • Pythonでのスパンマスク生成のスクラッチ実装
  • MLMとSpan Corruptionの比較実験による性能分析

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
マスク言語モデル(MLM)の理論と実装
BERTのMLMの仕組みと目的関数を解説します。Span Corruptionとの比較の基盤になります。
画像なし
T5のアーキテクチャと設計思想
T5のEncoder-Decoder構造とText-to-Textフレームワークの全体像を解説します。
画像なし
トークナイゼーションの基礎
テキストをトークンに分割する方法と、サブワード分割の仕組みを解説します。

MLMとSpan Corruptionの違い

穴埋め問題の「粒度」

Span Corruptionを理解するために、まずBERTのMLMとの違いを明確にしましょう。この2つの手法は、どちらも「テキストの一部を隠して復元する」という点では共通していますが、「何を隠すか」と「どう復元するか」において根本的に異なります。

MLMは、テストの穴埋め問題に例えると「各空欄が1単語分」の形式です。たとえば「The cat [MASK] on the [MASK]」のように、個々のトークンが独立にマスクされます。一方、Span Corruptionは「各空欄が数単語分」の形式です。「The <X> the <Y>」のように、連続する複数のトークンが1つの特殊トークン(Sentinel)にまとめて置き換えられます。

この違いを具体例で見てみましょう。

元のテキスト: The cat sat on the mat and looked out the window

MLM(BERT方式)の場合: – 入力: The cat [MASK] on the [MASK] and looked [MASK] the window – 予測: 各 [MASK] 位置でそれぞれ sat, mat, out を独立に予測 – マスク数: 3個のマスク → 3個の予測

Span Corruption(T5方式)の場合: – 入力: The cat <X> the <Y> the window – ターゲット: <X> sat on <Y> mat and looked out – スパン数: 2つのスパンを2つのSentinelで置換 → デコーダがスパンの中身を順次生成

ここで重要なのは、Span Corruptionでは「sat on」という2トークンのスパンが1つの <X> に、「mat and looked out」という4トークンのスパンが1つの <Y> に集約されている点です。このSentinelトークンによる集約が、計算効率の鍵となります。

MLMとSpan Corruptionの粒度の違い

上下に並べると違いが一目でわかります。上段のMLM(BERT方式)は3か所をそれぞれ独立の [MASK] で隠すため、マスク数だけ予測点が増えます。下段のSpan Corruptionは連続スパンをまとめて1つのSentinelに集約するので、入力に並ぶ箱の数自体が減っています。「穴の数」ではなく「穴の単位」が違うのです。

計算コストの違い

MLMの場合、入力系列の長さはマスク後もほぼ変わりません。元が11トークンなら、マスク後も11トークンです(マスクトークンが元のトークンと1対1で対応するため)。モデルは全11トークン分のEncoder出力を処理し、マスク位置のそれぞれについて語彙全体のsoftmaxを計算します。

Span Corruptionでは、入力系列とターゲット系列の両方が元の系列より短くなります。上の例では、入力は The cat <X> the <Y> the window の7トークン、ターゲットは <X> sat on <Y> mat and looked out の8トークンです。元が11トークンだったことを考えると、Encoderが処理するトークン数が削減されています。さらに重要なのは、T5の論文で採用された15%のcorruption rateでは、ターゲット系列の長さが元の系列長の約15%程度になることです。これにより、Decoderの計算コストが大幅に削減されます。

この計算効率の差は、大規模な事前学習では無視できないインパクトを持ちます。T5-11B(110億パラメータ)のような巨大モデルでは、ターゲット系列の短縮がそのまま学習速度の向上につながるのです。

Encoder-Only vs. Encoder-Decoder

もう1つの構造的な違いとして、アーキテクチャとの関係があります。MLMはEncoder-onlyアーキテクチャ(BERT)で用いられ、入力系列の各位置における文脈表現を学習します。一方、Span CorruptionはEncoder-Decoderアーキテクチャ(T5)で用いられ、入力テキストからターゲットテキストへの変換を学習します。

この「入力→出力」の変換という枠組みは、翻訳や要約といった下流タスクの形式と自然に一致します。事前学習の段階から「テキストを受け取ってテキストを生成する」という行為を繰り返すため、ファインチューニング時のタスク形式との乖離が小さくなるという利点があります。

T5のText-to-Text統一フレーム

この図は、T5が事前学習も下流タスクも同じ「テキスト→テキスト」の枠組みで扱うことを示しています。事前学習の破損復元、翻訳、要約、分類のいずれも、入力に小さなタスク指示を付けてEncoder-Decoderに通すだけです。Span Corruptionの「入力→ターゲット」という形が、この統一フレームの土台になっていることがわかります。

事前学習の目的関数そのものにも、いくつかの流儀があります。次の図で全体を俯瞰しておきましょう。

事前学習の3つの目的 LM MLM Span Corruptionの比較

左から、(1) 言語モデル(GPT系)は左から右へ次の単語を予測し、(2) MLM(BERT系)はマスク位置を独立に穴埋めし、(3) Span Corruption(T5系)はスパンをSentinelに集約して中身を自己回帰的に生成します。予測の「単位(トークンかスパンか)」と「方向(一方向か穴埋めか)」の組み合わせが手法ごとに異なり、Span CorruptionはMLMの穴埋めとLMの自己回帰生成の両方の性質を併せ持つ位置にあります。

ここまでで、MLMとSpan Corruptionの構造的な違いが明確になりました。では次に、Span Corruptionの数学的な定式化を行い、この手法の数理的基盤を整理しましょう。

Span Corruptionの数学的定式化

問題設定

Span Corruptionの数理を厳密に記述するために、まず表記法を定義します。日常の穴埋め問題を数式で表現すると言えば堅苦しく聞こえるかもしれませんが、やっていることは「どのトークンを隠すか」「隠した部分にどんなラベルを貼るか」「デコーダに何を予測させるか」を明確にすることです。

元のトークン系列を $\bm{x} = (x_1, x_2, \ldots, x_n)$ とします。ここで $x_i \in \mathcal{V}$ は語彙 $\mathcal{V}$ に含まれるトークンです。Span Corruptionでは、この系列から $k$ 個の連続するスパンを選びます。第 $j$ 番目のスパンを次のように定義します。

$$ S_j = (x_{s_j}, x_{s_j+1}, \ldots, x_{s_j + l_j – 1}) $$

ここで $s_j$ はスパンの開始位置、$l_j$ はスパンの長さです。各スパンは互いに重ならないものとします。

$$ [s_j, s_j + l_j – 1] \cap [s_{j’}, s_{j’} + l_{j’} – 1] = \emptyset \quad (j \neq j’) $$

Corruption(破損)操作

元の系列 $\bm{x}$ に対して、各スパン $S_j$ を対応するSentinelトークン $\langle s_j \rangle$ で置き換えます。Sentinelトークンは語彙に追加された特殊トークンで、$\langle s_1 \rangle, \langle s_2 \rangle, \ldots$ のように各スパンに固有のIDが割り当てられます。

破損後の入力系列 $\tilde{\bm{x}}$ は次のように構成されます。

$$ \tilde{\bm{x}} = \text{Corrupt}(\bm{x}, \{S_j\}_{j=1}^{k}) $$

具体的には、元の系列中のスパン部分をSentinelに置き換え、スパン外のトークンはそのまま保持します。

$$ \tilde{\bm{x}} = (x_1, \ldots, x_{s_1-1}, \langle s_1 \rangle, x_{s_1+l_1}, \ldots, x_{s_2-1}, \langle s_2 \rangle, x_{s_2+l_2}, \ldots) $$

この操作により、入力系列の長さは $n$ から $n – \sum_{j=1}^{k} l_j + k$ に短縮されます。つまり、マスクされたトークン総数 $\sum l_j$ 個が $k$ 個のSentinelに圧縮されるため、$\sum l_j – k$ トークン分だけ系列が短くなります。

ターゲット系列の構成

デコーダが予測すべきターゲット系列 $\bm{y}$ は、各Sentinelトークンと対応するスパンの中身を連結した形式です。

$$ \bm{y} = (\langle s_1 \rangle, x_{s_1}, x_{s_1+1}, \ldots, x_{s_1+l_1-1}, \langle s_2 \rangle, x_{s_2}, \ldots, x_{s_k+l_k-1}) $$

ここでSentinelトークンが各スパンの「区切り」として機能しています。デコーダは <s_1> を生成した後にスパン1の中身を生成し、次に <s_2> を生成してスパン2の中身を生成する、という流れです。ターゲット系列の末尾には、系列終端を示すEOSトークンが付加されます。

ターゲット系列の長さは $k + \sum_{j=1}^{k} l_j$ です。スパン数 $k$ 個のSentinelと、マスクされた全トークン $\sum l_j$ 個の合計です。元の系列長 $n$ に比べてこの長さがどれだけ短いかが、計算効率の決め手となります。

目的関数

Span Corruptionの学習目的は、破損入力 $\tilde{\bm{x}}$ を条件として、ターゲット系列 $\bm{y}$ を最大尤度推定で予測することです。具体的には、次の負の対数尤度を最小化します。

$$ \mathcal{L}(\theta) = -\sum_{t=1}^{|\bm{y}|} \log P_\theta(y_t \mid y_{

ここで $\theta$ はモデルパラメータ(Encoder + Decoderの全パラメータ)、$y_{

この目的関数を分解して理解しましょう。Encoderは破損入力 $\tilde{\bm{x}}$ を受け取り、文脈表現 $\bm{H} = \text{Encoder}(\tilde{\bm{x}})$ を計算します。Decoderは、この文脈表現 $\bm{H}$ とこれまでに生成したトークン $y_{

$$ P_\theta(y_t \mid y_{

ここで $\bm{W}_o \in \mathbb{R}^{|\mathcal{V}| \times d}$ は出力射影行列、$d$ はモデルの隠れ次元です。

MLMの目的関数との比較

比較のために、MLMの目的関数も記述しておきましょう。MLMでは、マスクされた位置の集合を $\mathcal{M}$ として、次の損失を最小化します。

$$ \mathcal{L}_{\text{MLM}}(\theta) = -\sum_{i \in \mathcal{M}} \log P_\theta(x_i \mid \tilde{\bm{x}}) $$

ここで注目すべき違いが2つあります。

まず、MLMでは各マスク位置の予測が条件付き独立です。$P(x_i \mid \tilde{\bm{x}})$ は他のマスク位置の正解には依存しません。これに対してSpan Corruptionでは、$P(y_t \mid y_{自己回帰的な予測です。つまり、スパン内のトークンを順番に生成するため、トークン間の依存関係を捉える能力が養われます。

次に、MLMの予測はEncoder出力の各マスク位置に対する分類問題です。対してSpan Corruptionは、Decoderによる系列生成問題です。前者は「各マスクを独立に穴埋め」、後者は「スパンの中身を左から右へ順に生成」という行為の違いが、モデルの学習する能力に質的な差をもたらします。

数学的定式化が整ったところで、次にSpan Corruptionの中核的なコンポーネントであるSentinelトークンの設計を詳しく見ていきましょう。

Sentinelトークンの仕組み

Sentinelトークンとは

Sentinelトークンは、マスクされたスパンの「代理人」として入力系列に挿入される特殊トークンです。BERTの [MASK] トークンとは異なり、各スパンに固有のIDが割り当てられます。T5では <extra_id_0>, <extra_id_1>, … という形式で、語彙の末尾に100個のSentinelトークンが追加されています。

日常の比喩で言えば、BERTの [MASK] は「空欄」を表す汎用マーカーですが、SentinelトークンはA、B、Cなどの「番号付きラベル」です。テストの穴埋め問題で「空欄Aに入る語を答えよ」「空欄Bに入る語を答えよ」と指定するのと同じ発想です。この番号のおかげで、デコーダは「今どのスパンを復元しているか」を明確に追跡できます。

Sentinelの割り当てルール

スパンへのSentinelの割り当ては、テキスト中の出現順に行います。最初に出現するスパンに <extra_id_0>、次のスパンに <extra_id_1> という具合です。

$$ \text{Sentinel}(S_j) = \langle \text{extra\_id}_{j-1} \rangle \quad (j = 1, 2, \ldots, k) $$

ターゲット系列でも同じSentinelが区切りとして使われるため、デコーダは生成中に「次のSentinelが出たら新しいスパンの復元に移る」という明確なシグナルを得ます。

なぜ固有IDが必要なのか

もしBERTのように全てのマスクに同じ [MASK] トークンを使ったらどうなるでしょうか。入力 The [MASK] the [MASK] the window では、2つの [MASK] が区別できません。Encoder側では位置エンコーディングによって多少の区別はつきますが、デコーダ側のターゲット系列では「どのマスクの中身を今生成しているか」が曖昧になります。

Sentinelに固有IDを持たせることで、入力側とターゲット側の対応関係が明示的になります。入力の <extra_id_0> がターゲットの <extra_id_0> に対応し、入力の <extra_id_1> がターゲットの <extra_id_1> に対応する — この1対1の対応により、デコーダはEncoder出力のどの部分を参照すべきかを自然に学習できます。

Sentinelトークンの入力とターゲットの1対1対応

破線が示すように、入力の <X> はターゲットの <X> と、入力の <Y> はターゲットの <Y> と一意に結びついています。デコーダはターゲットを生成する際、いま出力したSentinelがどの穴に対応するかを番号で追跡できます。[MASK] を共有するBERT方式では失われてしまうこの「どの穴を埋めているか」の情報が、固有IDによって保たれているのです。

Sentinelの埋め込み

実装上、Sentinelトークンは通常のトークンと同様に埋め込みベクトルを持ちます。語彙サイズ $|\mathcal{V}|$ の元の語彙にSentinel数 $N_s$(T5では100)を加えた $|\mathcal{V}| + N_s$ 次元の埋め込み行列が使われます。

$$ \bm{E} \in \mathbb{R}^{(|\mathcal{V}| + N_s) \times d} $$

Sentinelトークンの埋め込みは学習によって更新されます。事前学習を通じて、各Sentinelの埋め込みは「ここにスパンがある」「これは $j$ 番目のスパンである」という情報を符号化するように学習されます。

Sentinelトークンの設計が明確になりました。しかし、Span Corruptionの性能を左右するもう1つの重要な要素が残っています。それは「スパンの長さをどう決めるか」という問題です。次のセクションでは、T5で採用された幾何分布によるスパン長の決定メカニズムを解説します。

スパン長の幾何分布

なぜスパン長にランダム性が必要なのか

全てのスパンを固定長(たとえば3トークン)にするのが最も単純な設計ですが、T5ではスパン長を確率的に決定しています。なぜでしょうか?

直感的に考えてみましょう。自然言語テキストには、1語で完結する表現(冠詞、前置詞)もあれば、複数語で1つの意味単位を形成する表現(「machine learning」「New York City」)もあります。固定長のスパンでは、短い意味単位が途中で切れたり、長い意味単位が複数スパンに分断されたりします。さまざまな長さのスパンを混ぜることで、モデルはさまざまな粒度の言語構造を復元する能力を獲得します。

さらに、学習データのバリエーション増加という効果もあります。同じテキストから、毎回異なる長さのスパンが選ばれるため、事実上のデータ拡張として機能します。

幾何分布の定義

T5では、スパン長 $L$ を平均値 $\mu$ の幾何分布からサンプリングします。幾何分布は「最初の成功までの試行回数」を表す離散分布で、確率質量関数は次の通りです。

$$ P(L = l) = (1 – p)^{l-1} \cdot p \quad (l = 1, 2, 3, \ldots) $$

ここで $p$ は各試行での「成功確率」パラメータです。幾何分布の期待値(平均)は次のように求まります。

$$ \mathbb{E}[L] = \frac{1}{p} = \mu $$

$\mu$ から $p$ を逆算すると $p = 1/\mu$ です。T5の論文では平均スパン長 $\mu = 3$ を採用しているため、$p = 1/3$ となります。

この場合の各スパン長の確率は次のようになります。

$l = 1$ を代入すると $P(L=1) = (2/3)^0 \cdot (1/3) = 1/3 \approx 0.333$ です。

$l = 2$ では $P(L=2) = (2/3)^1 \cdot (1/3) = 2/9 \approx 0.222$ です。

$l = 3$ では $P(L=3) = (2/3)^2 \cdot (1/3) = 4/27 \approx 0.148$ です。

このように、短いスパンほど高い確率で出現し、長いスパンは低確率で出現します。これは自然言語における語句の長さの分布とも整合的です。大部分は短い表現ですが、まれに長い表現も出現する — この非対称性を幾何分布がうまく捉えています。

平均スパン長の効果

T5の論文では、平均スパン長 $\mu$ を2、3、5、10と変えた実験が報告されています。結果として $\mu = 3$ がバランスの取れた選択であることが示されました。

$\mu$ が小さすぎる(たとえば $\mu = 1$)場合、Span CorruptionはMLMに近づきます。各スパンが1トークンのみになるため、Sentinelの集約効果が薄れ、計算効率の利点が失われます。

$\mu$ が大きすぎる(たとえば $\mu = 10$)場合、少数の長いスパンがマスクされるため、スパン数 $k$ が減少します。スパン数が少ないと、Encoderに残る「ヒント」となるコンテキストトークンの連続性は高まりますが、デコーダが学習する「復元タスク」のバリエーションが減ります。また、非常に長いスパンの復元は難しすぎるため、学習が不安定になる可能性もあります。

Corruption rateとスパン数の関係

Span Corruptionでは、corruption rate $r$ (マスクするトークンの割合)と平均スパン長 $\mu$ の2つのハイパーパラメータが、生成されるスパンの数 $k$ を決定します。

元の系列長を $n$ とすると、マスクされるトークンの総数は $r \cdot n$ 個です。スパンの平均長が $\mu$ なので、期待されるスパン数は次のようになります。

$$ \mathbb{E}[k] = \frac{r \cdot n}{\mu} $$

T5のデフォルト設定($r = 0.15$、$\mu = 3$)では、100トークンの入力に対して期待されるスパン数は次のように計算できます。

$$ \mathbb{E}[k] = \frac{0.15 \times 100}{3} = 5 $$

つまり、100トークンの入力テキストから、平均5個のスパン(各スパンの平均長3トークン、合計15トークン)がマスクされます。

このとき、入力系列の長さは約 $100 – 15 + 5 = 90$ トークン、ターゲット系列の長さは約 $5 + 15 = 20$ トークンです。ターゲット系列が元の約20%の長さに圧縮されている点に注目してください。MLMでは入力もターゲットも100トークンのままなので、Decoderの計算コスト削減効果は明白です。

corruption rate の選択

T5の論文では、corruption rate $r$ も複数の値(10%、15%、25%、50%)で実験しています。結果として、$r = 0.15$(15%)が最も安定した性能を示しました。

$r$ が小さすぎると、マスクされるトークンが少なくなり、1回の学習ステップで得られる学習信号が弱くなります。$r$ が大きすぎると、入力テキストから情報が失われすぎて、Encoderが文脈を理解するのが困難になります。15%という値は、BERTのMLMで使われていた値と同じであり、マスク率としてはこの付近がスイートスポットであることが両手法から示唆されます。

ここまでで、Span Corruptionの理論的な構成要素 — スパンの定義、Sentinelトークン、幾何分布、corruption rate — が揃いました。次のセクションでは、これらをPythonで実装し、実際にスパンマスクを生成する過程を体験しましょう。

スパンマスク生成アルゴリズム

アルゴリズムの全体像

Span Corruptionのスパンマスク生成は、次のステップで行われます。

  1. マスク総数の決定: corruption rate $r$ と系列長 $n$ から、マスクするトークン数 $m = \lfloor r \cdot n \rfloor$ を計算する
  2. スパン長のサンプリング: 幾何分布からスパン長を繰り返しサンプリングし、合計が $m$ に達するまで続ける
  3. スパン開始位置の決定: 残りのトークン(非マスク)からランダムに開始位置を選ぶ
  4. 入力系列の構成: スパンをSentinelで置換した破損入力を生成する
  5. ターゲット系列の構成: Sentinelとスパンの中身を連結したターゲットを生成する

このアルゴリズムの鍵は、ステップ2と3の間にあります。スパン同士が重ならないように、かつランダムに配置する必要があるからです。T5の実装では、これを効率的に処理するために「非マスクトークンの間にスパンを均一に散らばらせる」というアプローチを取っています。

スパン生成の詳細

T5のオリジナル実装で使われるスパン配置のアイデアを説明します。まず、$n$ 個のトークンを「マスクされるトークン」と「マスクされないトークン」に分けます。マスクされる $m$ 個のトークンを $k$ 個のスパンに分割した後、$n – m$ 個の非マスクトークンを $k + 1$ 個のセグメントに分割し、スパンと交互に配置します。

この配置を実現するための具体的な手順は次の通りです。

  1. 幾何分布からスパン長をサンプリングし続け、合計が $m$ に達したらスパン長リストを確定する
  2. 非マスクトークン数 $n – m$ を $k + 1$ 個のセグメントにランダム分割する
  3. セグメントとスパンを交互に配置して、マスクの位置を決定する

ステップ2のランダム分割は、$n – m$ 個の区別できないボールを $k + 1$ 個の箱に入れる組合せ問題に相当します。これを一様ランダムに行うことで、スパンがテキスト全体に散らばるようになります。

理論的な理解が深まったところで、いよいよPythonでの実装に入りましょう。

Pythonでのスパンマスク生成実装

基本的なスパンマスク生成

まず、Span Corruptionのスパンマスク生成をスクラッチで実装します。このコードでは、幾何分布によるスパン長のサンプリングから、入力・ターゲット系列の構成までを一通り行います。

import numpy as np
from typing import List, Tuple

def sample_span_lengths(
    num_tokens: int,
    corruption_rate: float = 0.15,
    mean_span_length: float = 3.0,
    rng: np.random.Generator = None
) -> List[int]:
    """幾何分布からスパン長をサンプリングする"""
    if rng is None:
        rng = np.random.default_rng(42)

    # マスクする総トークン数
    num_masked = int(np.round(num_tokens * corruption_rate))
    if num_masked == 0:
        return []

    # 幾何分布のパラメータ p = 1/μ
    p = 1.0 / mean_span_length

    # スパン長を繰り返しサンプリング
    span_lengths = []
    total = 0
    while total < num_masked:
        # 幾何分布からサンプリング(最低1)
        length = rng.geometric(p)
        # 残りのトークン数を超えないようにクリップ
        length = min(length, num_masked - total)
        span_lengths.append(length)
        total += length

    return span_lengths


def create_span_corruption(
    token_ids: List[int],
    sentinel_start_id: int = 32000,
    corruption_rate: float = 0.15,
    mean_span_length: float = 3.0,
    seed: int = 42
) -> Tuple[List[int], List[int]]:
    """Span Corruptionによる入力系列とターゲット系列を生成する"""
    rng = np.random.default_rng(seed)
    n = len(token_ids)

    # スパン長をサンプリング
    span_lengths = sample_span_lengths(
        n, corruption_rate, mean_span_length, rng
    )
    k = len(span_lengths)  # スパン数
    if k == 0:
        return token_ids[:], []

    num_masked = sum(span_lengths)
    num_unmasked = n - num_masked

    # 非マスクトークンをk+1個のセグメントにランダム分割
    # (多項分布を使って均一にランダム配置)
    segment_lengths = np.zeros(k + 1, dtype=int)
    positions = rng.choice(num_unmasked + k, k, replace=False)
    positions.sort()
    # スターズ・アンド・バーズ法で分割
    positions_shifted = positions - np.arange(k)
    segment_lengths[0] = positions_shifted[0]
    for i in range(1, k):
        segment_lengths[i] = positions_shifted[i] - positions_shifted[i-1] - 1
    segment_lengths[k] = num_unmasked - positions_shifted[-1] - 1

    # マスク配列を構成
    mask = np.zeros(n, dtype=bool)
    pos = 0
    span_starts = []
    for i in range(k):
        pos += segment_lengths[i]  # 非マスクセグメント
        span_starts.append(pos)
        for j in range(span_lengths[i]):
            if pos + j < n:
                mask[pos + j] = True
        pos += span_lengths[i]

    # 入力系列の構成(スパンをSentinelで置換)
    input_ids = []
    sentinel_idx = 0
    i = 0
    while i < n:
        if mask[i]:
            # Sentinelトークンを挿入
            input_ids.append(sentinel_start_id + sentinel_idx)
            sentinel_idx += 1
            # スパンの終端までスキップ
            while i < n and mask[i]:
                i += 1
        else:
            input_ids.append(token_ids[i])
            i += 1

    # ターゲット系列の構成
    target_ids = []
    sentinel_idx = 0
    i = 0
    while i < n:
        if mask[i]:
            target_ids.append(sentinel_start_id + sentinel_idx)
            sentinel_idx += 1
            while i < n and mask[i]:
                target_ids.append(token_ids[i])
                i += 1
        else:
            i += 1

    return input_ids, target_ids

# デモ: テキストをトークンIDに見立てて実行
np.random.seed(42)
# 簡易トークン列(0〜19の整数をトークンIDに見立てる)
tokens = list(range(20))
print(f"元の系列:       {tokens}")
print(f"系列長:         {len(tokens)}")

input_ids, target_ids = create_span_corruption(
    tokens, sentinel_start_id=100, corruption_rate=0.15, mean_span_length=3.0
)
print(f"入力系列:       {input_ids}")
print(f"ターゲット系列: {target_ids}")
print(f"入力長: {len(input_ids)}, ターゲット長: {len(target_ids)}")

このコードの出力を見てみましょう。元の20トークンの系列に対して、15%にあたる3トークンが幾何分布に基づくスパンとしてマスクされ、Sentinelトークン(100, 101, …)で置き換えられています。入力系列では複数のトークンが1つのSentinelに集約されているため系列長が短くなり、ターゲット系列にはSentinelと対応するスパンの中身が格納されています。入力長とターゲット長の合計が元の系列長よりわずかにSentinelの分だけ多い(Sentinelが入力とターゲットの両方に含まれるため)ことも確認できます。

スパン長の分布を可視化する

次に、幾何分布からサンプリングされるスパン長の分布を可視化し、理論分布との一致を確認します。

import numpy as np
import matplotlib.pyplot as plt

# スパン長のサンプリングを大量に繰り返す
rng = np.random.default_rng(42)
mean_span_length = 3.0
p = 1.0 / mean_span_length
num_samples = 50000

sampled_lengths = rng.geometric(p, size=num_samples)

# 理論分布
max_length = 15
l_values = np.arange(1, max_length + 1)
theoretical_probs = (1 - p) ** (l_values - 1) * p

# 経験分布
empirical_probs = np.array([
    np.sum(sampled_lengths == l) / num_samples for l in l_values
])

# 可視化
fig, ax = plt.subplots(figsize=(10, 6))
width = 0.35
ax.bar(l_values - width/2, theoretical_probs, width, label='Geometric (theory)',
       color='#00bcd4', alpha=0.8, edgecolor='white')
ax.bar(l_values + width/2, empirical_probs, width, label='Sampled (empirical)',
       color='#ff9800', alpha=0.8, edgecolor='white')
ax.set_xlabel('Span Length', fontsize=13)
ax.set_ylabel('Probability', fontsize=13)
ax.set_title(f'Span Length Distribution (mean={mean_span_length})', fontsize=14)
ax.set_xticks(l_values)
ax.legend(fontsize=12)
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.savefig('span_length_distribution.png', dpi=150, bbox_inches='tight')
plt.show()

print(f"理論平均: {1/p:.2f}")
print(f"経験平均: {np.mean(sampled_lengths):.2f}")
print(f"理論分散: {(1-p)/p**2:.2f}")
print(f"経験分散: {np.var(sampled_lengths):.2f}")

スパン長の幾何分布 理論と経験の一致

上のグラフから2つの重要な特徴が読み取れます。第一に、理論分布(水色)と経験分布(オレンジ)がほぼ完全に一致しており、NumPyの幾何分布サンプラーが正しく機能していることが確認できます。第二に、スパン長1の確率が約33%と最も高く、長さが増えるにつれて確率が急速に減衰していることがわかります。これは幾何分布の「メモリレス性」の帰結であり、各トークン位置で独立に1/3の確率で「スパン終了」が起きることに対応します。経験平均と理論平均もほぼ一致し、十分なサンプル数で幾何分布の特性が正しく再現されていることがわかります。

異なる平均スパン長の比較

平均スパン長 $\mu$ を変えたときに、スパン長の分布がどう変化するかを可視化しましょう。

import numpy as np
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 3, figsize=(15, 5))
mean_lengths = [2, 3, 5]
colors = ['#e91e63', '#00bcd4', '#4caf50']

for ax, mu, color in zip(axes, mean_lengths, colors):
    p = 1.0 / mu
    l_values = np.arange(1, 16)
    probs = (1 - p) ** (l_values - 1) * p

    ax.bar(l_values, probs, color=color, alpha=0.8, edgecolor='white')
    ax.set_xlabel('Span Length', fontsize=12)
    ax.set_ylabel('Probability', fontsize=12)
    ax.set_title(f'μ = {mu} (p = {p:.3f})', fontsize=13)
    ax.set_xticks(l_values[::2])
    ax.set_ylim(0, 0.55)
    ax.grid(axis='y', alpha=0.3)
    # 期待値を縦線で表示
    ax.axvline(x=mu, color='red', linestyle='--', alpha=0.7, label=f'E[L]={mu}')
    ax.legend(fontsize=11)

plt.suptitle('Span Length Distribution for Different Mean Lengths',
             fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig('span_length_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

異なる平均スパン長での分布比較

3つの分布を比較すると、$\mu$ が大きくなるにつれて分布の裾が右に広がり、長いスパンが出現する確率が高まることがわかります。$\mu = 2$ では大部分のスパンが長さ1-3に集中し、MLMに近い挙動になります。$\mu = 5$ では長さ5以上のスパンも無視できない頻度で出現し、モデルに長いフレーズの復元能力が要求されます。T5が $\mu = 3$ を採用した理由は、この中間的なバランスにあると考えられます。短いスパンによる頻繁な学習信号と、適度に長いスパンによる文脈理解の促進を両立させる値です。

実際のテキストでのSpan Corruption可視化

Span Corruptionの効果をより直感的に理解するために、実際のテキスト(を模した単語列)に適用して可視化します。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

def visualize_span_corruption(
    words: list,
    corruption_rate: float = 0.15,
    mean_span_length: float = 3.0,
    seed: int = 42
):
    """テキストに対するSpan Corruptionを可視化する"""
    n = len(words)
    token_ids = list(range(n))

    input_ids, target_ids = create_span_corruption(
        token_ids, sentinel_start_id=1000,
        corruption_rate=corruption_rate,
        mean_span_length=mean_span_length,
        seed=seed
    )

    # マスク位置を特定
    masked_positions = set()
    sentinel_spans = {}
    sentinel_count = 0

    for i, tid in enumerate(input_ids):
        if tid >= 1000:
            sentinel_spans[tid] = sentinel_count
            sentinel_count += 1

    # 元のtoken_idから復元
    mask_array = np.zeros(n, dtype=int)  # 0: unmasked, 1+: sentinel_id
    pos = 0
    sid = 0
    for iid in input_ids:
        if iid >= 1000:
            # このsentinelに対応する元のトークンを特定
            span_tokens = []
            in_target = False
            current_sentinel = iid
            for tid in target_ids:
                if tid == current_sentinel:
                    in_target = True
                    continue
                elif tid >= 1000:
                    if in_target:
                        break
                    continue
                elif in_target:
                    span_tokens.append(tid)
            for t in span_tokens:
                mask_array[t] = sid + 1
            sid += 1
        else:
            pos += 1

    # 可視化
    fig, ax = plt.subplots(figsize=(16, 4))
    colors_map = plt.cm.Set2(np.linspace(0, 1, max(sid, 1) + 1))

    for i, word in enumerate(words):
        if mask_array[i] > 0:
            bg_color = colors_map[mask_array[i] - 1]
            label = f'<extra_id_{mask_array[i]-1}>'
        else:
            bg_color = '#2a2a3e'
            label = None

        rect = mpatches.FancyBboxPatch(
            (i * 1.6, 0.5), 1.4, 0.8,
            boxstyle="round,pad=0.1",
            facecolor=bg_color,
            edgecolor='white' if mask_array[i] == 0 else 'red',
            linewidth=1.5
        )
        ax.add_patch(rect)
        ax.text(i * 1.6 + 0.7, 0.9, word,
                ha='center', va='center', fontsize=9,
                color='white' if mask_array[i] == 0 else 'black',
                fontweight='bold')
        if mask_array[i] > 0:
            ax.text(i * 1.6 + 0.7, 0.6, f'<id_{mask_array[i]-1}>',
                    ha='center', va='center', fontsize=7,
                    color='red', fontstyle='italic')

    ax.set_xlim(-0.2, len(words) * 1.6 + 0.2)
    ax.set_ylim(0, 2)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title('Span Corruption Visualization', fontsize=14, pad=10)

    # 凡例
    legend_elements = [
        mpatches.Patch(facecolor='#2a2a3e', edgecolor='white', label='Unmasked'),
        mpatches.Patch(facecolor=colors_map[0], edgecolor='red', label='Masked Span'),
    ]
    ax.legend(handles=legend_elements, loc='upper right', fontsize=10)

    plt.tight_layout()
    plt.savefig('span_corruption_visualization.png', dpi=150, bbox_inches='tight')
    plt.show()

# サンプルテキスト
words = ["The", "quick", "brown", "fox", "jumps", "over",
         "the", "lazy", "dog", "and", "runs", "into",
         "the", "dark", "forest", "at", "night"]
visualize_span_corruption(words, corruption_rate=0.15, mean_span_length=3.0, seed=42)

実テキストへのSpan Corruption可視化

可視化の結果を見ると、テキスト中のいくつかの位置に色付きの連続ブロック(マスクされたスパン)が出現していることがわかります。各スパンには固有のSentinel ID(<id_0>, <id_1>, …)が割り当てられています。マスクされていないトークン(暗い背景色)は入力系列にそのまま残り、Encoderがこの文脈情報をもとに、マスクされたスパンの内容をDecoderに伝える役割を果たします。

ここまでのスクラッチ実装で、Span Corruptionの内部動作を詳細に理解できました。次に、このSpan CorruptionとBERTのMLMを定量的に比較する実験を行い、両手法の特性の違いを実データで確認しましょう。

MLMとSpan Corruptionの比較実験

実験の目的

ここでは、同一のテキストに対してMLMとSpan Corruptionをそれぞれ適用したときの、以下の指標を比較します。

  1. 入力系列とターゲット系列の長さ: 計算効率の指標
  2. マスクパターンの多様性: 同一テキストから生成されるバリエーションの豊富さ
  3. スパン長の分布特性: 1回の学習で復元すべき情報量の分布

これにより、Span Corruptionがどのような場面でMLMより優れた特性を持つかを実証的に確認します。

MLMのマスク生成実装

比較のために、BERTスタイルのMLM(トークン単位のランダムマスク)も実装します。

import numpy as np
from typing import List, Tuple

def create_mlm_masking(
    token_ids: List[int],
    mask_token_id: int = 99,
    corruption_rate: float = 0.15,
    seed: int = 42
) -> Tuple[List[int], List[int], List[int]]:
    """BERTスタイルのMLMマスキングを実行する"""
    rng = np.random.default_rng(seed)
    n = len(token_ids)
    num_masked = int(np.round(n * corruption_rate))

    # ランダムにマスク位置を選択
    mask_positions = sorted(rng.choice(n, num_masked, replace=False))

    # 入力系列(マスクトークンで置換)
    input_ids = token_ids[:]
    for pos in mask_positions:
        input_ids[pos] = mask_token_id

    # ターゲット: マスク位置の正解トークン
    target_tokens = [token_ids[pos] for pos in mask_positions]

    return input_ids, target_tokens, mask_positions

このMLM実装はBERTの簡易版です。実際のBERTでは80%をマスク、10%をランダムトークンに置換、10%をそのまま保持するという戦略が取られますが、ここでは比較の簡明さのため全てマスクトークンに置換しています。

系列長の比較実験

同じテキストにMLMとSpan Corruptionを適用し、生成される系列の長さを系統的に比較します。

import numpy as np
import matplotlib.pyplot as plt

def compare_sequence_lengths(
    seq_lengths: list,
    corruption_rate: float = 0.15,
    mean_span_length: float = 3.0,
    num_trials: int = 100
):
    """複数の系列長でMLMとSpan Corruptionの出力長を比較する"""
    results = {
        'seq_len': [],
        'mlm_input_len': [], 'mlm_target_len': [],
        'sc_input_len': [], 'sc_target_len': []
    }

    for n in seq_lengths:
        mlm_inputs, mlm_targets = [], []
        sc_inputs, sc_targets = [], []

        for trial in range(num_trials):
            tokens = list(range(n))

            # MLM
            mlm_in, mlm_tgt, _ = create_mlm_masking(
                tokens, mask_token_id=99,
                corruption_rate=corruption_rate, seed=trial
            )
            mlm_inputs.append(len(mlm_in))
            mlm_targets.append(len(mlm_tgt))

            # Span Corruption
            sc_in, sc_tgt = create_span_corruption(
                tokens, sentinel_start_id=100,
                corruption_rate=corruption_rate,
                mean_span_length=mean_span_length, seed=trial
            )
            sc_inputs.append(len(sc_in))
            sc_targets.append(len(sc_tgt))

        results['seq_len'].append(n)
        results['mlm_input_len'].append(np.mean(mlm_inputs))
        results['mlm_target_len'].append(np.mean(mlm_targets))
        results['sc_input_len'].append(np.mean(sc_inputs))
        results['sc_target_len'].append(np.mean(sc_targets))

    return results

seq_lengths = [32, 64, 128, 256, 512]
results = compare_sequence_lengths(seq_lengths)

# 可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 入力系列長
ax = axes[0]
ax.plot(results['seq_len'], results['mlm_input_len'],
        'o-', color='#e91e63', linewidth=2, markersize=8, label='MLM Input')
ax.plot(results['seq_len'], results['sc_input_len'],
        's-', color='#00bcd4', linewidth=2, markersize=8, label='Span Corruption Input')
ax.plot(results['seq_len'], results['seq_len'],
        '--', color='gray', alpha=0.5, label='Original Length')
ax.set_xlabel('Original Sequence Length', fontsize=13)
ax.set_ylabel('Input Sequence Length', fontsize=13)
ax.set_title('Input Sequence Length Comparison', fontsize=14)
ax.legend(fontsize=11)
ax.grid(alpha=0.3)

# ターゲット系列長
ax = axes[1]
ax.plot(results['seq_len'], results['mlm_target_len'],
        'o-', color='#e91e63', linewidth=2, markersize=8, label='MLM Target')
ax.plot(results['seq_len'], results['sc_target_len'],
        's-', color='#00bcd4', linewidth=2, markersize=8, label='Span Corruption Target')
ax.plot(results['seq_len'], results['seq_len'],
        '--', color='gray', alpha=0.5, label='Original Length')
ax.set_xlabel('Original Sequence Length', fontsize=13)
ax.set_ylabel('Target Sequence Length', fontsize=13)
ax.set_title('Target Sequence Length Comparison', fontsize=14)
ax.legend(fontsize=11)
ax.grid(alpha=0.3)

plt.tight_layout()
plt.savefig('sequence_length_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

# 数値比較
print("=" * 60)
print(f"{'SeqLen':>8} | {'MLM Input':>10} {'MLM Tgt':>10} | "
      f"{'SC Input':>10} {'SC Tgt':>10} | {'Tgt Ratio':>10}")
print("-" * 60)
for i, n in enumerate(results['seq_len']):
    ratio = results['sc_target_len'][i] / results['mlm_target_len'][i]
    print(f"{n:>8} | {results['mlm_input_len'][i]:>10.1f} "
          f"{results['mlm_target_len'][i]:>10.1f} | "
          f"{results['sc_input_len'][i]:>10.1f} "
          f"{results['sc_target_len'][i]:>10.1f} | {ratio:>10.2f}")

入力ターゲット系列長のMLM vs Span Corruption比較

この比較実験の結果から、2つの重要な知見が得られます。まず入力系列について、MLMの入力長は常に元の系列長と同一(マスクトークンが1対1で置換されるため)ですが、Span Corruptionの入力長は元の系列長より約10%短くなります。これはスパンの集約効果です。次にターゲット系列について、MLMのターゲットはマスクされたトークンのIDリスト(系列長の15%)であるのに対し、Span Corruptionのターゲットにはスパンの中身に加えてSentinelトークンも含まれるため、MLMのターゲットよりもやや長くなります。しかし、Span CorruptionのターゲットはT5のDecoderが自己回帰的に生成するものであり、MLMのように語彙全体の分類を全マスク位置で独立に行うわけではないため、計算コストの単純な比較は系列長だけでは完結しません。

マスクパターンの多様性

同一テキストに異なるシードでマスクを適用した場合に、どの程度異なるパターンが生成されるかを定量化します。

import numpy as np
import matplotlib.pyplot as plt

def compute_mask_diversity(
    num_tokens: int = 100,
    corruption_rate: float = 0.15,
    mean_span_length: float = 3.0,
    num_trials: int = 500
):
    """マスクパターンの多様性を測定する"""
    tokens = list(range(num_tokens))

    # 各トークン位置がマスクされる頻度を集計
    mlm_freq = np.zeros(num_tokens)
    sc_freq = np.zeros(num_tokens)

    for trial in range(num_trials):
        # MLM
        _, _, mask_positions = create_mlm_masking(
            tokens, corruption_rate=corruption_rate, seed=trial
        )
        for pos in mask_positions:
            mlm_freq[pos] += 1

        # Span Corruption
        sc_in, _ = create_span_corruption(
            tokens, sentinel_start_id=1000,
            corruption_rate=corruption_rate,
            mean_span_length=mean_span_length, seed=trial
        )
        # 入力系列に含まれないトークンIDを特定
        remaining_ids = set(tid for tid in sc_in if tid < 1000)
        for i in range(num_tokens):
            if i not in remaining_ids:
                sc_freq[i] += 1

    mlm_freq /= num_trials
    sc_freq /= num_trials

    return mlm_freq, sc_freq

mlm_freq, sc_freq = compute_mask_diversity(num_tokens=100, num_trials=500)

fig, axes = plt.subplots(2, 1, figsize=(14, 8))

# MLMのマスク頻度
axes[0].bar(range(100), mlm_freq, color='#e91e63', alpha=0.7, edgecolor='none')
axes[0].axhline(y=0.15, color='black', linestyle='--', alpha=0.5, label='Expected Rate (15%)')
axes[0].set_xlabel('Token Position', fontsize=12)
axes[0].set_ylabel('Masking Frequency', fontsize=12)
axes[0].set_title('MLM: Per-Position Masking Frequency (500 trials)', fontsize=13)
axes[0].set_ylim(0, 0.3)
axes[0].legend(fontsize=11)
axes[0].grid(axis='y', alpha=0.3)

# Span Corruptionのマスク頻度
axes[1].bar(range(100), sc_freq, color='#00bcd4', alpha=0.7, edgecolor='none')
axes[1].axhline(y=0.15, color='black', linestyle='--', alpha=0.5, label='Expected Rate (15%)')
axes[1].set_xlabel('Token Position', fontsize=12)
axes[1].set_ylabel('Masking Frequency', fontsize=12)
axes[1].set_title('Span Corruption: Per-Position Masking Frequency (500 trials)', fontsize=13)
axes[1].set_ylim(0, 0.3)
axes[1].legend(fontsize=11)
axes[1].grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.savefig('mask_diversity_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

# 統計量の比較
print(f"MLM       - 平均マスク率: {np.mean(mlm_freq):.4f}, "
      f"分散: {np.var(mlm_freq):.6f}")
print(f"Span Corr - 平均マスク率: {np.mean(sc_freq):.4f}, "
      f"分散: {np.var(sc_freq):.6f}")

上のグラフから、MLMとSpan Corruptionのマスクパターンの統計的性質に興味深い違いが見えます。MLMでは各位置のマスク頻度が期待値0.15の周りに均一にばらついています。これはトークン単位の独立なベルヌーイ試行の結果であり、大数の法則に従って頻度が安定しています。一方、Span Corruptionでは系列の端付近でマスク頻度がやや低く、中央付近でやや高くなる傾向が見られる場合があります。これはスパンの配置が系列の境界条件の影響を受けるためです。全体的な平均マスク率は両手法とも15%に近い値を示し、corruption rateの設計が正しく機能していることが確認できます。

corruption rateが系列長に与える影響

最後に、corruption rateを変えたときのSpan Corruptionのターゲット系列長の変化を調べます。

import numpy as np
import matplotlib.pyplot as plt

corruption_rates = [0.10, 0.15, 0.20, 0.25, 0.30, 0.50]
seq_len = 256
num_trials = 200

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ターゲット系列長
mean_target_lens = []
std_target_lens = []
mean_input_lens = []
mean_num_spans = []

for rate in corruption_rates:
    target_lens = []
    input_lens = []
    num_spans = []
    for trial in range(num_trials):
        tokens = list(range(seq_len))
        sc_in, sc_tgt = create_span_corruption(
            tokens, sentinel_start_id=1000,
            corruption_rate=rate, mean_span_length=3.0, seed=trial
        )
        target_lens.append(len(sc_tgt))
        input_lens.append(len(sc_in))
        # Sentinel数を数える
        n_sentinels = sum(1 for t in sc_in if t >= 1000)
        num_spans.append(n_sentinels)

    mean_target_lens.append(np.mean(target_lens))
    std_target_lens.append(np.std(target_lens))
    mean_input_lens.append(np.mean(input_lens))
    mean_num_spans.append(np.mean(num_spans))

# 左: ターゲット系列長
ax = axes[0]
ax.errorbar(corruption_rates, mean_target_lens, yerr=std_target_lens,
            fmt='o-', color='#00bcd4', linewidth=2, markersize=8,
            capsize=5, label='Target Length')
ax.plot(corruption_rates, mean_input_lens,
        's-', color='#ff9800', linewidth=2, markersize=8, label='Input Length')
ax.axhline(y=seq_len, color='gray', linestyle='--', alpha=0.5,
           label=f'Original ({seq_len})')
ax.set_xlabel('Corruption Rate', fontsize=13)
ax.set_ylabel('Sequence Length', fontsize=13)
ax.set_title('Effect of Corruption Rate on Sequence Length', fontsize=14)
ax.legend(fontsize=11)
ax.grid(alpha=0.3)

# 右: スパン数
ax = axes[1]
theoretical_spans = [rate * seq_len / 3.0 for rate in corruption_rates]
ax.plot(corruption_rates, mean_num_spans,
        'o-', color='#4caf50', linewidth=2, markersize=8, label='Empirical')
ax.plot(corruption_rates, theoretical_spans,
        '--', color='red', linewidth=2, alpha=0.7, label='Theoretical (rn/μ)')
ax.set_xlabel('Corruption Rate', fontsize=13)
ax.set_ylabel('Number of Spans', fontsize=13)
ax.set_title('Number of Spans vs Corruption Rate', fontsize=14)
ax.legend(fontsize=11)
ax.grid(alpha=0.3)

plt.tight_layout()
plt.savefig('corruption_rate_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

# 数値の表示
print(f"{'Rate':>6} | {'Input Len':>10} {'Target Len':>12} | "
      f"{'#Spans':>8} {'Theory':>8} | {'Target/Orig':>12}")
print("-" * 65)
for i, rate in enumerate(corruption_rates):
    print(f"{rate:>6.2f} | {mean_input_lens[i]:>10.1f} "
          f"{mean_target_lens[i]:>12.1f} | "
          f"{mean_num_spans[i]:>8.1f} {theoretical_spans[i]:>8.1f} | "
          f"{mean_target_lens[i]/seq_len:>12.2%}")

corruption rateが系列長とスパン数に与える影響

この実験から3つの重要な知見が得られます。第一に、corruption rateが大きくなるにつれてターゲット系列長が線形に増加し、入力系列長が線形に減少します。rate=0.50では入力とターゲットの長さがほぼ同じになり、Span Corruptionの計算効率の利点が薄れます。第二に、スパン数の実測値が理論値 $rn/\mu$ にほぼ一致しており、幾何分布によるスパン長サンプリングが設計通りに機能していることが確認できます。第三に、T5のデフォルト設定(rate=0.15)では、ターゲット系列長が元の系列長の約20%に圧縮されており、Decoderの計算コストが大幅に削減されていることが定量的に示されています。

Span Corruptionの発展と関連手法

SpanBERTとの関係

Span Corruptionと似た発想で、BERTのMLMをスパン単位に拡張した手法にSpanBERT(Joshi et al., 2020)があります。SpanBERTでは、幾何分布ではなく一様分布からスパン長をサンプリングし、Encoder-onlyアーキテクチャのままスパン単位のマスクを行います。また、SpanBERTではSpan Boundary Objective(SBO)という補助的な損失を導入し、スパンの境界位置の表現からスパン内部のトークンを予測させることで、境界表現の質を向上させています。

T5のSpan CorruptionとSpanBERTの主な違いは、アーキテクチャ(Encoder-Decoder vs. Encoder-only)とスパン長の分布(幾何分布 vs. 一様分布)です。T5の幾何分布は短いスパンを多く生成するため、BERTの1トークンマスクからの移行がスムーズである一方、SpanBERTの一様分布は全ての長さを等しい確率で生成するため、長いスパンの学習機会が増えます。

UL2とMixture of Denoisers

T5のSpan Corruptionをさらに発展させた手法として、UL2(Tay et al., 2023)のMixture of Denoisersがあります。UL2では、単一のcorruption rateと平均スパン長ではなく、複数のノイズ設定を混合して事前学習を行います。

具体的には、以下の3種類のデノイザーを組み合わせます。

  • R-Denoiser(Regular): 通常のSpan Corruption(短いスパン、低いcorruption rate)
  • S-Denoiser(Sequential): 自己回帰的なプレフィクスLM(テキストの後半を生成)
  • X-Denoiser(Extreme): 高いcorruption rate + 長いスパン

これにより、1つのモデルが「穴埋め」「生成」「極端なノイズ除去」の全てのスキルを獲得し、多様な下流タスクに柔軟に対応できるようになります。UL2は、Span Corruptionの「corruption rateと平均スパン長は固定でよいのか?」という疑問に対する1つの回答と言えます。

BART方式との比較

Facebook(Meta)のBARTは、Span Corruptionに加えて、文のシャッフル、トークンの削除、ドキュメントの回転など、複数の種類のノイズを組み合わせた事前学習を行います。T5のSpan Corruptionが「スパン単位のマスク → 復元」という1種類のノイズに特化しているのに対し、BARTは多様なノイズの混合によりロバストな表現を目指します。

両者の比較実験からは、Span Corruptionの単純さが必ずしも弱点ではないことが示されています。T5はその単純な事前学習タスクを、膨大なデータ(C4: 750GB)と十分な計算資源で徹底的にスケールすることにより、複雑なノイズ戦略を用いるモデルと同等以上の性能を達成しました。

これらの発展的手法は、Span Corruptionの基本的なアイデア — 「テキストの一部をスパン単位で破損し、それを復元する」— の上に構築されています。Span Corruptionの数理を理解していれば、これらの派生手法の設計思想も自然に理解できるでしょう。

まとめ

本記事では、T5方式の事前学習手法であるSpan Corruptionについて、理論と実装の両面から詳細に解説しました。

  • MLMとの構造的な違い: BERTのMLMがトークン単位で独立にマスクするのに対し、Span Corruptionは連続するトークンのスパンを1つのSentinelトークンで置換し、Decoderがスパンの中身を自己回帰的に生成します。これにより、トークン間の依存関係を捉える能力が養われます

  • Sentinelトークンの役割: 各スパンに固有IDを持つSentinelが割り当てられ、入力系列とターゲット系列の対応関係を明示的に保持します。この設計により、Decoderは複数のスパンを区別しながら復元を行えます

  • 幾何分布によるスパン長の決定: 平均スパン長 $\mu = 3$、$p = 1/\mu$ の幾何分布からスパン長をサンプリングします。短いスパンが高頻度で出現し、長いスパンが低頻度で出現するこの分布は、自然言語の語句の長さの分布と整合的です

  • 計算効率の優位性: corruption rate 15%の設定では、ターゲット系列が元の系列長の約20%に圧縮されるため、Decoderの計算コストが大幅に削減されます。この効率性が、T5の大規模事前学習を実現可能にした要因の1つです

  • Corruption rateと平均スパン長のトレードオフ: corruption rate $r = 0.15$、平均スパン長 $\mu = 3$ という設定が、学習信号の強さと文脈情報の保持のバランスにおいて最適であることを、T5の体系的実験が示しています

Span Corruptionの理解は、T5だけでなく、SpanBERT、UL2、BARTなど、スパンベースの事前学習手法全体を見通す基盤となります。特にUL2のMixture of Denoisersは、Span Corruptionを「1つのノイズ設定」として組み込み、複数の設定を混合するという自然な拡張であり、Span Corruptionの数理がそのまま活用されています。

次のステップとして、以下の記事も参考にしてください。

画像なし
マスク言語モデル(MLM)の理論と実装
BERTのMLMの仕組みと目的関数を解説します。Span Corruptionとの比較の基盤です。
画像なし
T5のアーキテクチャと設計思想
T5のEncoder-Decoder構造とText-to-Textフレームワークの全体像を解説します。
画像なし
トークナイゼーションの基礎
テキストをトークンに分割する方法と、サブワード分割の仕組みを解説します。