自然言語処理(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, ... に対して を当てるように学習します。入力とラベルを 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) * BA(A: 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 では、テンプレート設計、損失マスク、系列長管理がそのまま品質とコストへ跳ね返ります。