自然言語処理(NLP)

自然言語処理では、文字列をそのまま扱うのではなく、まず学習可能なトークン列へ変換します。そのうえで埋め込みを学習し、次トークン予測や SFT のような目的に合わせて損失を掛けます。

このノートでは、トークン化、語彙、埋め込み、次トークン予測、SFT の損失マスク、LoRA / QLoRA の計算感覚までを一本の流れで見ます。

NLP の難しさは、モデルの前に「文字列をどう数にするか」から始まる

文章は、そのままではニューラルネットワークへ入れられません。どこで切るか、未知語をどう扱うか、どこに損失を掛けるか。この前処理と学習目的の設計が、後段のモデル品質をかなり左右します。

日本語の文を split(" ") した瞬間に、NLP の前処理が雑には済まないことが分かります。この notebook ではその違和感を出発点にして、語彙化、埋め込み、次トークン予測、SFT の損失マスク、LoRA 系の軽量更新までを「どこに学習信号を流すか」という観点でつなげます。

前半は表現の話、後半は損失の話です

同じ文章でも、単語で切るか文字で切るかで系列長も未知語の出方も変わります。さらに、学習時に系列のどこへ損失を掛けるかで、モデルが覚えるものも変わります。この二段階で読むと整理しやすくなります。

日本語を題材にすると、tokenization の雑さがすぐ露呈する

英語の空白分割は直感的でも、日本語では単語境界が明示されません。だから tokenization を軽視すると、最初の入力表現の時点でかなりの情報を落とします。

LLM の学習でも、本質は「どこに答えを書かせるか」を決めること

next-token prediction では全位置で次を当てますが、SFT では回答部分だけに損失を掛けることが多いです。LoRA / QLoRA も含めて、後半は巨大モデルの特殊技法というより、更新対象を絞る設計として読んでください。

小さな例でも、LLM の作法はかなり見えてくる

語彙、埋め込み、シフトしたラベル、損失マスク、軽量 fine-tuning。規模は小さくても、考えていること自体はそのまま大規模モデルへつながります。

まずはトークン化で何が起きるかを見る

最初に、同じ文を空白分割と文字分割で比べます。日本語で空白分割が素直に機能しないことが、ここでかなりはっきり見えるはずです。

import math
import re
from collections import Counter

import numpy as np
import matplotlib.pyplot as plt

try:
    import torch
    import torch.nn as nn
    import torch.optim as optim
    TORCH_AVAILABLE = True
except ModuleNotFoundError:
    torch = None
    nn = None
    optim = None
    TORCH_AVAILABLE = False

最初の関門はトークン化です。
同じ文でも、単語単位で切るか、文字単位で切るかで系列長や未知語の扱いが変わります。

日本語は空白で単語境界が明示されないため、split(' ') は失敗しやすい方法です。
ここでは、まず失敗例として空白分割を見たあと、文字単位分割と比較します。

raw_texts = [
    'LLMは文脈に応じて次の単語を予測する。',
    'SFTでは指示と回答のペアを教師信号にする。',
    'モデル評価では正答率だけでなく出力品質も見る。',
    '未知語が多いと語彙外トークンが増えて性能が落ちやすい。',
    '日本語でも英語でもトークン化の設計は重要。',
]


def normalize_text(s):
    s = s.lower()
    s = re.sub(r'[。、,,.!?!?]', ' ', s)
    s = re.sub(r'\s+', ' ', s).strip()
    return s


def whitespace_tokenize(s):
    return normalize_text(s).split(' ')


def char_tokenize(s):
    s = normalize_text(s)
    s = s.replace(' ', '')
    return list(s)


ws_tokenized = [whitespace_tokenize(t) for t in raw_texts]
char_tokenized = [char_tokenize(t) for t in raw_texts]

for i in range(len(raw_texts)):
    print(f'--- sample {i} ---')
    print('whitespace:', ws_tokenized[i])
    print('char      :', char_tokenized[i][:20], '...')

ws_lengths = np.array([len(t) for t in ws_tokenized], dtype=np.int64)
char_lengths = np.array([len(t) for t in char_tokenized], dtype=np.int64)

print('\nmean length (whitespace) =', float(ws_lengths.mean()))
print('mean length (char)       =', float(char_lengths.mean()))

# 以降は外部トークナイザ依存を避けるため、文字単位を使用
tokenized = char_tokenized

次に語彙と ID 変換を作る

トークン列をモデルへ入れるには、まず語彙表を作り、各トークンを整数 ID へ変換する必要があります。<pad><unk> は、そのための運用上の部品です。

x = np.arange(len(raw_texts))
width = 0.36

plt.figure(figsize=(7.2, 3.6))
plt.bar(x - width/2, ws_lengths, width=width, label='whitespace split', color='#7aa2ff')
plt.bar(x + width/2, char_lengths, width=width, label='char split', color='#8dd3a7')
plt.xticks(x, [f's{i}' for i in range(len(raw_texts))])
plt.ylabel('token count')
plt.title('Token length by tokenization strategy')
plt.legend()
plt.tight_layout()
plt.show()

次に語彙(vocabulary)を作って、トークンをIDへ変換します。
<pad><unk> を先頭に置くのは実務でもよくある設計です。

このノートでは文字単位トークンを使っているので、未知語問題は「未知文字」の形で現れます。<unk> が出たときは、その文字の情報を細かく失って「未知として一括処理した」ことを意味します。

counter = Counter(tok for toks in tokenized for tok in toks)
special_tokens = ['<pad>', '<unk>']
base_vocab = [tok for tok, _ in counter.most_common()]
vocab = special_tokens + base_vocab
stoi = {tok: i for i, tok in enumerate(vocab)}
itos = {i: tok for tok, i in stoi.items()}


def encode(tokens):
    unk = stoi['<unk>']
    return [stoi.get(tok, unk) for tok in tokens]


def decode(ids):
    return [itos.get(i, '<unk>') for i in ids]


encoded = [encode(toks) for toks in tokenized]
print('vocab size =', len(vocab))
print('first sample ids =', encoded[0])
print('decoded back      =', decode(encoded[0]))

embedding は単なる表引きから始まる

埋め込み層は最初はただの表引きですが、学習が進むと似た使われ方のトークンが近いベクトルになっていきます。ここで「意味がどこに入るのか」の入口を掴みます。

if TORCH_AVAILABLE:
    torch.manual_seed(0)

    mini_sentences = [
        'モデル 学習 損失 最適化',
        'モデル 訓練 データ 評価',
        '文章 単語 文脈 トークン',
        '文脈 予測 モデル 生成',
        '訓練 最適化 損失 収束',
    ]

    word_tokens = [s.split(' ') for s in mini_sentences]
    w_vocab = sorted(set(tok for toks in word_tokens for tok in toks))
    w_stoi = {w: i for i, w in enumerate(w_vocab)}

    pairs = []
    window = 1
    for toks in word_tokens:
        ids = [w_stoi[t] for t in toks]
        for i, center in enumerate(ids):
            for j in range(max(0, i - window), min(len(ids), i + window + 1)):
                if i != j:
                    pairs.append((center, ids[j]))

    emb = nn.Embedding(len(w_vocab), 16)
    out = nn.Linear(16, len(w_vocab), bias=False)
    opt = optim.Adam(list(emb.parameters()) + list(out.parameters()), lr=5e-2)
    criterion = nn.CrossEntropyLoss()

    x_train = torch.tensor([c for c, _ in pairs], dtype=torch.long)
    y_train = torch.tensor([ctx for _, ctx in pairs], dtype=torch.long)

    with torch.no_grad():
        init_vec = emb.weight.clone()

    for _ in range(220):
        h = emb(x_train)
        logits = out(h)
        loss = criterion(logits, y_train)
        opt.zero_grad()
        loss.backward()
        opt.step()

    def cos(v1, v2):
        return float(torch.dot(v1, v2) / (torch.norm(v1) * torch.norm(v2) + 1e-12))

    i_model = w_stoi['モデル']
    i_train = w_stoi['訓練']
    init_sim = cos(init_vec[i_model], init_vec[i_train])
    trained_sim = cos(emb.weight[i_model].detach(), emb.weight[i_train].detach())

    print('vocab:', w_vocab)
    print('cos(model, train) before learning =', round(init_sim, 4))
    print('cos(model, train) after learning  =', round(trained_sim, 4))
else:
    rng = np.random.default_rng(0)
    emb = rng.normal(0, 0.4, size=(6, 8))
    v1, v2 = emb[0], emb[1]
    sim = float(np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-12))
    print('PyTorch未導入のため学習前ランダム埋め込みのみ表示します。')
    print('random cosine =', round(sim, 4))

言語モデルは 1 トークン先を当て続ける

次トークン予測では、系列 x_0, x_1, ... に対して xt+1x_{t+1} を当てるように学習します。入力とラベルを 1 つずらして並べる理由はここにあります。

# 手計算に近い最小クロスエントロピー例
logits = np.array([1.2, -0.4, 0.3, 2.0], dtype=np.float64)
target_id = 3

logits_shift = logits - np.max(logits)
probs = np.exp(logits_shift) / np.sum(np.exp(logits_shift))
loss = -math.log(probs[target_id] + 1e-12)

print('probs =', np.round(probs, 4))
print('target id =', target_id)
print('cross entropy =', round(loss, 6))

SFT では、どこに損失を掛けるかが重要

指示文、入力、回答を 1 本の系列へ連結しても、学習したいのは普通「回答の出し方」です。そこで回答部分だけへ損失を掛け、指示部分は無視する設計がよく使われます。

sft_examples = [
    {
        'instruction': '次の文を要約してください。',
        'input': 'Transformerは系列全体を同時に参照できるため、長距離依存を扱いやすい。',
        'output': 'Transformerは長距離依存を扱いやすい。',
    },
    {
        'instruction': '用語を説明してください。',
        'input': 'SFT',
        'output': '教師ありデータでモデル応答を調整する学習。',
    },
]


def format_sft(ex):
    return (
        '<system>あなたは丁寧なAIアシスタントです。</system>\n'
        f"<user>{ex['instruction']}\n{ex['input']}</user>\n"
        f"<assistant>{ex['output']}</assistant>"
    )


formatted = [format_sft(ex) for ex in sft_examples]
for i, text in enumerate(formatted):
    print(f'--- sample {i} ---')
    print(text)

LoRA / QLoRA は何を軽くしているのか

LoRA は大きな重み行列全体を更新せず、低ランク行列だけを学習することで更新コストを下げます。QLoRA はそこへ量子化を組み合わせて、さらにメモリを節約します。

# 文字単位の簡易トークナイズで「回答部のみloss」を可視化
chars = sorted(set(''.join(formatted)))
char_vocab = ['<pad>', '<unk>'] + chars
c_stoi = {c: i for i, c in enumerate(char_vocab)}


def encode_chars(s):
    unk = c_stoi['<unk>']
    return [c_stoi.get(ch, unk) for ch in s]


ignore_index = -100
for i, text in enumerate(formatted):
    ids = encode_chars(text)

    start_tag = '<assistant>'
    end_tag = '</assistant>'
    start_pos = text.find(start_tag)
    end_pos = text.find(end_tag)

    labels = [ignore_index] * len(ids)
    if start_pos >= 0 and end_pos > start_pos:
        start = start_pos + len(start_tag)
        end = end_pos
        for j in range(start, end):
            labels[j] = ids[j]

    # 右シフト後に実際に損失へ入るラベルを計算
    input_ids = ids[:-1]
    target_ids = labels[1:]

    active = sum(1 for v in target_ids if v != ignore_index)
    print(f'sample {i}: input_len={len(input_ids)}, supervised_after_shift={active}, ratio={active/max(len(input_ids),1):.3f}')

次に、LoRA/QLoRA の計算量感覚を押さえます。
大きな重み行列 W を丸ごと更新せず、低ランク行列 A, B だけを学習するのがLoRAです。

W' = W + (alpha / r) * BAA: r x d_in, B: d_out x r)なので、追加パラメータは r*(d_in + d_out) です。
QLoRAではベース重みを量子化し、LoRA部分だけ高精度で更新します。

def lora_param_count(d_in, d_out, rank):
    full = d_in * d_out
    lora = rank * (d_in + d_out)
    return full, lora


for rank in [4, 8, 16, 32]:
    full, lora = lora_param_count(d_in=4096, d_out=4096, rank=rank)
    print(f'rank={rank:>2d}: full={full:,}, lora={lora:,}, ratio={lora/full:.6f}')

# QLoRAの直感: baseを4bit量子化し、adapterは通常精度で学習
base_params = 7_000_000_000
base_16bit_gb = base_params * 16 / 8 / (1024**3)
base_4bit_gb = base_params * 4 / 8 / (1024**3)
print('\nbase model memory (approx, weights only):')
print('fp16:', round(base_16bit_gb, 2), 'GB')
print('4bit:', round(base_4bit_gb, 2), 'GB')
print('note: optimizer state / activations / metadataは別途必要')

最後に小さな文字レベル LM を動かす

ここまで見てきたトークン化、語彙、次トークン予測の考え方を、小さな言語モデルへつなぎます。実際の LLM はずっと大きいですが、骨格は同じです。

if TORCH_AVAILABLE:
    torch.manual_seed(0)

    corpus = [
        'transformerは文脈を使って次を予測する',
        'sftは指示と回答のペアで学習する',
        'loraは追加パラメータを小さくできる',
        'token化と語彙設計は性能に効く',
    ]

    text = '\n'.join(corpus)
    vocab_chars = sorted(set(text))
    vocab = ['<unk>'] + vocab_chars
    stoi = {ch: i for i, ch in enumerate(vocab)}
    itos = {i: ch for ch, i in stoi.items()}
    unk_id = stoi['<unk>']

    data = torch.tensor([stoi.get(ch, unk_id) for ch in text], dtype=torch.long)

    block_size = 24
    batch_size = 32

    def get_batch():
        idx = torch.randint(0, len(data) - block_size - 1, (batch_size,))
        x = torch.stack([data[i:i+block_size] for i in idx])
        y = torch.stack([data[i+1:i+block_size+1] for i in idx])
        return x, y

    class TinyCharLM(nn.Module):
        def __init__(self, vocab_size, d_model=64):
            super().__init__()
            self.token_emb = nn.Embedding(vocab_size, d_model)
            self.rnn = nn.GRU(d_model, d_model, batch_first=True)
            self.head = nn.Linear(d_model, vocab_size)

        def forward(self, x):
            h = self.token_emb(x)
            out, _ = self.rnn(h)
            logits = self.head(out)
            return logits

    model = TinyCharLM(vocab_size=len(vocab), d_model=64)
    opt = optim.AdamW(model.parameters(), lr=3e-3)
    criterion = nn.CrossEntropyLoss()

    for step in range(220):
        xb, yb = get_batch()
        logits = model(xb)
        loss = criterion(logits.reshape(-1, len(vocab)), yb.reshape(-1))

        opt.zero_grad()
        loss.backward()
        opt.step()

        if step % 55 == 0:
            print(f'step={step:>3d}, loss={loss.item():.4f}')

    model.eval()
    prompt = 'sftは?'
    unknown_count = sum(1 for ch in prompt if ch not in stoi)
    ids = [stoi.get(ch, unk_id) for ch in prompt]
    print('prompt unknown chars replaced with <unk> =', unknown_count)

    x = torch.tensor(ids, dtype=torch.long).unsqueeze(0)

    for _ in range(40):
        logits = model(x)
        next_id = torch.argmax(logits[:, -1, :], dim=-1, keepdim=True)
        x = torch.cat([x, next_id], dim=1)

    generated = ''.join(itos[i] if i != unk_id else '□' for i in x.squeeze(0).tolist())
    print('\nGenerated text:')
    print(generated)
else:
    print('PyTorch未導入のため、言語モデル実験セルはスキップしました。')

NLP では、モデル構造だけでなくデータ整形が性能を大きく左右します。特に SFT では、テンプレート設計、損失マスク、系列長管理がそのまま品質とコストへ跳ね返ります。