Powered by AppSignal & Oban Pro

LLM を Elixir / Livebook で学ぶ 1

01_llm_overview_tokens_embeddings.livemd

LLM を Elixir / Livebook で学ぶ 1

# 数値計算、表、グラフの表示に使うライブラリを準備する
Mix.install([
  {:nx, "~> 0.9"},
  {:kino, "~> 0.15"},
  {:kino_vega_lite, "~> 0.1"}
])

このシリーズについて

このシリーズは、次の 1 本の問いを少しずつ広げながら、LLM と Transformer の仕組みを Livebook 上で確かめていきます。

モデルは、何を手がかりに次のトークンを予測するのか?

内容 この問いへの答え方
1(この章) 全体像・トークン化・埋め込み 文章を「計算できる形」に直す
2 極小モデルの学習 「学習で予測が変わる」を最小構成で体験する
3 アテンション・因果マスク・位置情報 直前の 1 語ではなく「文脈全体」を見る仕組みを知る
4 ミニ GPT の組み立てと学習 部品を組み合わせ、文脈を読むモデルを実際に学習する
5 事前学習済み GPT-2 実物のモデルでトークン化と生成を観察する
6 アラインメント(SFT / DPO) モデルを人の好みに合わせる調整を体験する
発展編 エンコーダー・デコーダー構成 翻訳型 Transformer とクロスアテンションを知る

この章で分かること

この章では、LLM のいちばん最初の入口である「文章を数値へ変換する部分」に絞ります。

  • GPT 系モデルは全体として何をしているか
  • トークン化すると何が起きるか
  • one-hot 表現には何が足りないか
  • 埋め込みベクトルは何をしているか

アテンションや学習はまだ登場しません。まず「モデルに渡るデータの形」を手で確かめます。

このnotebookで使う用語

最初からすべて暗記する必要はありません。分からない言葉が出てきたときに、この表へ戻って確認してください。

用語 英語・コード上の表記 この章での意味
LLM Large Language Model 大量の文章から言葉の続き方を学んだ大規模言語モデル
次トークン予測 next-token prediction ここまでのトークンから、次の1トークンを当てる問題
トークン token モデルが文章を処理するときの単位。単語・単語の一部・記号など
語彙 vocabulary / vocab モデルが区別できるトークンの一覧
トークンID token ID 各トークンへ割り当てた整数番号
one-hot表現 one-hot encoding そのトークンの位置だけを1、ほかを0にしたベクトル
埋め込み embedding トークンIDを、計算に使える実数ベクトルへ変換したもの
内積 dot product 2つのベクトルの各要素を掛けて足した値。向きが近いほど大きくなる
デコーダーのみのモデル decoder-only model 左側の文脈から次トークンを予測する構成のモデル。GPT系がこれにあたる

準備

defmodule LLMScratch.Visuals do
  # 行データを、カテゴリごとの大きさを比べる棒グラフへ変換する
  def bar_chart(rows, title, x_field, y_field, opts \\ []) do
    width = Keyword.get(opts, :width, 520)
    height = Keyword.get(opts, :height, 260)

    VegaLite.new(width: width, height: height, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:bar, tooltip: true)
    |> VegaLite.encode_field(
      :x, Atom.to_string(x_field),
      type: :nominal,
      title: Atom.to_string(x_field)
    )
    |> VegaLite.encode_field(
      :y, Atom.to_string(y_field),
      type: :quantitative,
      title: Atom.to_string(y_field)
    )
    |> Kino.VegaLite.new()
  end

  # 各トークンの埋め込みを2次元の平面に置き、近さを目で確かめる散布図
  def labeled_scatter(rows, title, x_field, y_field, label_field) do
    VegaLite.new(width: 420, height: 340, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.layers([
      VegaLite.new()
      |> VegaLite.mark(:circle, size: 140, tooltip: true)
      |> VegaLite.encode_field(:x, Atom.to_string(x_field), type: :quantitative)
      |> VegaLite.encode_field(:y, Atom.to_string(y_field), type: :quantitative),
      VegaLite.new()
      |> VegaLite.mark(:text, dy: -14)
      |> VegaLite.encode_field(:x, Atom.to_string(x_field), type: :quantitative)
      |> VegaLite.encode_field(:y, Atom.to_string(y_field), type: :quantitative)
      |> VegaLite.encode_field(:text, Atom.to_string(label_field), type: :nominal)
    ])
    |> Kino.VegaLite.new()
  end

  # 2つのトークン軸と数値を、色の濃さで読めるヒートマップにする
  def heatmap(rows, title, x_field, y_field, color_field, opts \\ []) do
    width = Keyword.get(opts, :width, 520)
    height = Keyword.get(opts, :height, 480)

    VegaLite.new(width: width, height: height, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:rect, tooltip: true)
    |> VegaLite.encode_field(:x, Atom.to_string(x_field), type: :nominal, title: Atom.to_string(x_field))
    |> VegaLite.encode_field(:y, Atom.to_string(y_field), type: :nominal, title: Atom.to_string(y_field))
    |> VegaLite.encode_field(:color, Atom.to_string(color_field), type: :quantitative, scale: [scheme: "blues"])
    |> Kino.VegaLite.new()
  end
end

まず GPT 系モデルの全体像

細かい部品を見る前に、最終的に何を作ろうとしているのかを確認します。

GPT 系モデルがしていることを一言で表すと、ここまでのトークンを読み、次の 1 トークンを予測する です。

Kino.Mermaid.new("""
flowchart TD
  A["入力: ねこ は ひるね が"] --> B["a. トークンに分ける"]
  B --> C["b. 各トークンをベクトルにする"]
  C --> D["c. 位置情報を足す"]
  D --> E["d. 左側の文脈をアテンションで読む"]
  E --> F["e. 次トークンの確率を出す"]
  F --> G["候補: すき 0.72 / 得意 0.08 / ..."]
  G --> H["選んだ「すき」を入力末尾へ追加"]
  H --> E
""")
部品 まず持っておきたい役割のイメージ 学ぶ章
トークン化 文章をモデルが扱う部品へ分ける この章
埋め込み 各部品を計算できるベクトルへ変える この章
位置情報 同じトークンでも、何番目にあるかを区別する手がかりを足す 第3章
アテンション 今の位置に必要な情報を、前の各位置から集める 第3章
出力層 集めた情報から次トークン候補へ点数を付ける 第2章・第4章

この章で扱うのは、図の ab、つまり入口の2つです。

デコーダーのみ(decoder-only)とは

Transformer には、文章を読み取るエンコーダーと、1トークンずつ出力するデコーダーという構成要素があります。GPTは、そのうち デコーダー側の仕組みを中心に積み重ねたモデルなので、デコーダーのみ(decoder-only)モデルと呼ばれます。

いまの時点では、次の2点だけ覚えておけば十分です。

  • GPT は、入力の続きを左から右へ 1 トークンずつ予測する
  • その予測に、まだ答えとして与えていない右側のトークンを使ってはいけない

「右側を見ない」を実現する因果マスクは第3章で、エンコーダーとデコーダーを組み合わせた翻訳型の構成は発展編で扱います。

1. トークン化

ここでは説明しやすいように、空白で区切られた簡単な文を使います。

# この章では、説明しやすい空白区切りを簡易トークナイザーとして使う
sentence = "ねこ は ひるね が すき"
tokens = String.split(sentence, " ")

実際の LLM は、単語より細かい「サブワード」という単位を使います(第5章で実物を観察します)。分け方は違っても、文章 -> トークンの列 に直すという目的は同じです。

# 重複を除いたトークン一覧を、この小さな例の語彙とする
vocab =
  tokens
  |> Enum.uniq()
  |> Enum.sort()

# 文字列を計算可能な整数IDへ変換する対応表
token_to_id =
  vocab
  |> Enum.with_index()
  |> Map.new()

# 予測したIDを文字列へ戻すため、逆向きの対応表も用意する
id_to_token =
  token_to_id
  |> Enum.map(fn {token, id} -> {id, token} end)
  |> Map.new()

token_ids = Enum.map(tokens, &token_to_id[&1])

%{
  vocab: vocab,
  token_to_id: token_to_id,
  token_ids: token_ids
}
# 各位置について、元のトークンと割り当てられたIDを並べる
token_rows =
  tokens
  |> Enum.with_index()
  |> Enum.map(fn {token, position} ->
    %{
      position: position,
      token: token,
      token_id: token_to_id[token]
    }
  end)

Kino.DataTable.new(token_rows)

LLM にとって、最初から「ねこ」の意味が分かっているわけではありません。まずは単に ID に置き換えて、計算できる形に直しています。

2. one-hot 表現とその限界

ID はただの番号なので、そのまま足したり掛けたりしても意味のある計算になりません。そこで最初に思いつくのが、one-hot 表現です。one-hot は「その語が語彙の何番目か」だけを表すベクトルです。

vocab_size = length(vocab)

# IDと同じ位置だけが1になるone-hotベクトルを、系列順に積み重ねる
sequence_one_hot =
  token_ids
  |> Enum.map(fn id ->
    Nx.equal(Nx.iota({vocab_size}), Nx.tensor(id))
  end)
  |> Nx.stack()

one-hot には大きな弱点があります。どの2語を比べても「まったく似ていない」ことになる点です。

ベクトル同士の近さは内積(各要素を掛けて足した値)で測れます。one-hot 同士の内積を全組み合わせで計算してみます。

# 語彙の全トークン分のone-hotを作り、全組み合わせの内積を求める
vocab_one_hot =
  Nx.equal(
    Nx.new_axis(Nx.iota({vocab_size}), 1),
    Nx.iota({vocab_size})
  )
  |> Nx.as_type(:f32)

one_hot_similarity = Nx.dot(vocab_one_hot, [1], vocab_one_hot, [1])

one_hot_similarity_rows =
  for {token_a, i} <- Enum.with_index(vocab),
      {token_b, j} <- Enum.with_index(vocab) do
    %{
      token_a: token_a,
      token_b: token_b,
      dot: Nx.to_number(one_hot_similarity[[i, j]])
    }
  end

LLMScratch.Visuals.heatmap(
  one_hot_similarity_rows,
  "one-hot 表現の内積(全組み合わせ)",
  :token_b,
  :token_a,
  :dot
)

ヒートマップを見ると、色が付くのは対角線(自分自身、内積 1)だけで、それ以外はすべて内積 0 の同じ色になっていることが一目で分かります。つまり one-hot の世界では、「ねこ」と「ひるね」の関係も、「ねこ」と「が」の関係も、まったく同じ「無関係」になってしまいます。

また、実際の LLM の語彙は数万〜十数万トークンあるため、1トークンを表すのに数万個の 0 と 1 個の 1 を使うのは無駄が多すぎます。

3. 埋め込み: 意味の近さを持てるベクトル

そこで LLM では、one-hot の代わりに、より低次元で密な実数ベクトルである埋め込みを使います。

ここでは説明用に、2 次元の手作り埋め込みを置きます。

# 説明用に、人手で2次元の埋め込みを割り当てる
# 実際のLLMでは、この値も学習によって更新される
embedding_map = %{
  "ねこ" => [0.90, 0.15],
  "は" => [0.10, 0.05],
  "ひるね" => [0.95, 0.85],
  "が" => [0.12, 0.10],
  "すき" => [0.88, 0.70]
}

# 表で値を確認できるよう、埋め込みを行データへ整形する
embedding_rows =
  vocab
  |> Enum.map(fn token ->
    [x, y] = embedding_map[token]

    %{
      token: token,
      dim_1: x,
      dim_2: y
    }
  end)

Kino.DataTable.new(
  embedding_rows,
  keys: [:token, :dim_1, :dim_2]
)

2次元なので、そのまま平面に置いて眺められます。

LLMScratch.Visuals.labeled_scatter(
  embedding_rows,
  "手作り埋め込みの平面図",
  :dim_1,
  :dim_2,
  :token
)

この図では、内容を持つ語(ねこ・ひるね・すき)を右上寄りに、機能的な助詞(は・が)を左下寄りに置いてあります。one-hot と違って、埋め込みなら「この語とこの語は近い」を座標として表現できます。

# 文中の各トークンを埋め込みへ置き換え、{系列長, 埋め込み次元}のtensorにする
embedding_tensor =
  tokens
  |> Enum.map(&embedding_map[&1])
  |> Nx.tensor(type: {:f, 32})

embedding_tensor

内積で「近さ」を数値にする

one-hot のときと同じように、埋め込み同士の内積を全組み合わせで計算し、同じ配色のヒートマップで表示してみます。

embedding_similarity = Nx.dot(embedding_tensor, [1], embedding_tensor, [1])

embedding_similarity_rows =
  for {token_a, i} <- Enum.with_index(tokens),
      {token_b, j} <- Enum.with_index(tokens) do
    %{
      token_a: token_a,
      token_b: token_b,
      dot: Float.round(Nx.to_number(embedding_similarity[[i, j]]), 3)
    }
  end

LLMScratch.Visuals.heatmap(
  embedding_similarity_rows,
  "埋め込みの内積(全組み合わせ)",
  :token_b,
  :token_a,
  :dot
)
# 「すき」から見た各トークンとの内積を棒グラフで比べる
query_index = Enum.find_index(tokens, &(&1 == "すき"))

similarity_bar_rows =
  tokens
  |> Enum.with_index()
  |> Enum.map(fn {token, index} ->
    %{
      token: token,
      dot: Nx.to_number(embedding_similarity[[query_index, index]])
    }
  end)

LLMScratch.Visuals.bar_chart(similarity_bar_rows, "`すき` と各トークンの内積", :token, :dot)

one-hot ではどの組み合わせも 0 でしたが、埋め込みでは すきひるね の内積が大きく、すき の内積は小さくなります。ベクトルの向きが近いほど内積が大きいためです。

この「内積で相性を測る」という操作は、この章だけの小技ではありません。第3章で学ぶアテンションは、まさにこの内積を使って「どの位置のトークンを参照するか」を決めます。ここで感覚をつかんでおいてください。

4. 埋め込みは学習で決まる

この章では埋め込みを人手で置きましたが、実際の LLM では次のように扱われます。

  • 埋め込みは {語彙数, 埋め込み次元} の大きな表として持つ
  • 最初は乱数で初期化する
  • 学習が進むにつれて、「似た働きをするトークン」や「一緒に現れやすいトークン」が近いベクトルへ動いていく

第4章でミニ GPT を学習するとき、この埋め込み表もモデルの一部として実際に更新されます。

5. まとめ

この章の要点

  • LLM の仕事は「ここまでのトークンから次の 1 トークンを予測する」こと
  • トークン化は、文章を計算可能な部品(トークン ID の列)へ分解する作業
  • one-hot は語を区別できるが、語同士の近さを表せない
  • 埋め込みは、意味の近さを内積や距離として扱える実数ベクトル
  • 内積で「相性」を測る考え方は、第3章のアテンションへ直接つながる

次のノートブックでは、「学習とは何か」を最小構成で体験します。直前の 1 トークンだけを見る極小の言語モデル(bigram)を実際に学習し、損失が下がると予測がどう変わるかを観察します。