技術 約19分で読めます

Qwen-ScopeのSAEをM1 Max 64GBで動かして日本語特徴を取り出す

いけさん目次

Qwen-Scope の紹介記事を書いたあと、結局これって手元で動くのかが気になった。 公式モデルカードのコード例は torch.float32 + CPU で書かれていて、M1 Max 64GBで素直に動くのかは別問題だ。 やってみた記録をそのまま書く。

環境

項目内容
ハードウェアMac Studio M1 Max 64GB
OSmacOS Darwin 25.3.0
Python3.13.11 (miniconda)
PyTorch2.11.0 (MPS有効)
transformers5.7.0
対象モデルQwen/Qwen3-8B-Base
対象SAEQwen/SAE-Res-Qwen3-8B-Base-W64K-L0_50
推論精度bf16
開始時ディスク残量81GB

Qwen3-8B-Baseがbf16で約16GB、SAE 1層あたり約2.0GBなので、欲張らずに中間層1つだけ取得する方針。

セットアップ

scripts/experiments/qwen-scope/ に venv を切って、最小構成で進める。

python3 -m venv venv
source venv/bin/activate
pip install torch transformers huggingface_hub safetensors

バージョンは環境表のとおり。MPS確認は torch.backends.mps.is_available()True を確認。

ダウンロードはモデル本体とSAE 1層分だけ。

from huggingface_hub import snapshot_download, hf_hub_download
snapshot_download("Qwen/Qwen3-8B-Base", allow_patterns=["*.safetensors", "*.json", "tokenizer*", "merges.txt", "vocab.json"])
hf_hub_download("Qwen/SAE-Res-Qwen3-8B-Base-W64K-L0_50", "layer17.sae.pt")

実測値は次のとおり。

  • Qwen3-8B-Base 12ファイル: 約260秒(約16GB)
  • SAE layer17(中間層): 約255秒(2.0GB)
  • ディスク残: 81GB → 63GB

SAEの36層全部だと72GBで、本体16GBと足したらフリースペースを食い尽くす。中間層1つから始める判断は正解だった。

走査スクリプト

公式モデルカードのコード例を、bf16 + MPS に書き換えた最小版が以下。 全文は scripts/experiments/qwen-scope/run_sae_layer17.py に置いた。

import torch
from huggingface_hub import hf_hub_download
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL = "Qwen/Qwen3-8B-Base"
SAE_REPO = "Qwen/SAE-Res-Qwen3-8B-Base-W64K-L0_50"
LAYER, TOPK = 17, 50

device = torch.device("mps")
dtype = torch.bfloat16

sae = torch.load(hf_hub_download(SAE_REPO, f"layer{LAYER}.sae.pt"),
                 map_location="cpu", weights_only=True)
W_enc = sae["W_enc"].to(device=device, dtype=dtype)
b_enc = sae["b_enc"].to(device=device, dtype=dtype)

tokenizer = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=dtype, low_cpu_mem_usage=True).to(device).eval()

captured = {}
def _hook(_m, _i, output):
    captured["residual"] = (output[0] if isinstance(output, tuple) else output).detach()

hook = model.model.layers[LAYER].register_forward_hook(_hook)
inputs = tokenizer("The capital of France is", return_tensors="pt").to(device)
with torch.no_grad():
    model(**inputs)
hook.remove()

pre_acts = captured["residual"] @ W_enc.T + b_enc  # (1, T, 65536)
topk = pre_acts.topk(TOPK, dim=-1)

公式コードはfp32+CPUで書かれているが、bf16+MPSに置き換えても普通に動く。SAEは行列積1回 + topk だけなので、bf16の精度落ちで困る場面は今のところ出ていない。

単発プロンプトで上位特徴を見る

3つのプロンプトで上位5特徴IDと値を比較する('Ġ' は単語先頭スペースのBPEトークン記号)。

英文: "The capital of France is"

postokentop-5 feature IDstop-5 values
0The34149, 52900, 27004, 6003, 63112640, 1432, 1144, 976, 976
1Ġcapital37019, 24229, 53623, 1339, 436837.2, 16.5, 14.9, 14.3, 13.8
2Ġof4049, 24229, 18523, 37019, 2804021.1, 17.6, 16.5, 14.2, 13.0
3ĠFrance33343, 28040, 5649, 24229, 1547022.8, 13.4, 12.2, 11.2, 11.0
4Ġis56437, 24229, 28040, 18523, 1173321.5, 16.2, 14.3, 12.2, 11.3

コード: "import numpy as np"

postokentop-5 feature IDstop-5 values
0import34149, 26873, 8655, 11238, 259332,637,824, 901120, 876544, 679936, 630784
1Ġnumpy34149, 52900, 27004, 6311, 60033072, 1656, 1328, 1144, 1128
2Ġas62043, 39165, 34153, 5551, 5649105.5, 55.8, 28.1, 17.5, 16.2
3Ġnp26489, 1027, 46998, 22005, 3414942.0, 35.8, 33.2, 28.2, 21.5

日本語: "今日はいい天気だ"

postokentop-5 feature IDstop-5 values
0今日は34149, 52900, 27004, 6311, 60033600, 1936, 1552, 1336, 1328
1いい23991, 28040, 1339, 28720, 5362319.4, 15.7, 14.5, 13.3, 13.1
217063, 23991, 1789, 28040, 945227.5, 20.8, 19.5, 18.2, 14.3
323991, 1789, 28040, 32889, 945232.5, 22.5, 19.2, 16.8, 13.7
48805, 23991, 28040, 11916, 945217.8, 17.6, 15.4, 13.1, 11.6

3つのプロンプトで気になる挙動が出た。

1. 先頭トークンの「アテンションシンク」特徴 34149

3プロンプトすべての position 0 で、特徴 34149 が支配的。 さらに 52900, 27004, 6003, 6311 という同じクラスタが上位を占める。 これは attention sink として知られる現象で、内容に関係なく先頭トークンの residual stream が一定パターンに収束する。

2. import の異常な活性値(2.6M)

英文・日本語の先頭トークンが2640〜3600なのに対し、import の position 0 は 2,637,824 とおよそ1000倍の値が出た。 他の上位特徴も全部100万オーダー。 bf16 でこの値域を扱っているのでオーバーフローを疑ったが、これは後述のfp32再走で別の話だとわかる。

3. 日本語特徴 23991

日本語プロンプトの position 1〜4 で特徴 23991 が4回登場する。 英語・コードのほうには上位5に出てこない。 1プロンプトでは偶然かもしれないので、この点も統計を取って後で検証する。

fp32で再走したら値はほぼ同じだった

import の2.6Mがbf16オーバーフローなのか、それともモデル固有の挙動なのかを切り分けるため、dtype=torch.float32 に変えて同じプロンプトを通した。

プロンプトbf16 token0 値fp32 token0 値
The capital of France is2640.002546.30
import numpy as np2,637,8242,642,047
今日はいい天気だ3600.003593.45

fp32 と bf16 でtop-5特徴IDも順番もほぼ同じ。値の差は1%以内。 import の2.6Mはモデル本体側で生まれている活性化マグニチュードで、SAE通過後にそのまま受け取っているだけだった。 ベースモデルが import という単一トークンに対して残差ストリーム上で異常に大きいベクトルを出している、という解釈になる。

bf16精度問題ではないとわかったので、以降は bf16 のまま続行。 bf16+MPSでもfp32と top-5 IDが完全一致し値も1%以内。SAEの特徴解釈用途ならbf16で十分実用になる。

メモリは fp32 で 32GB、bf16 で 16GB。M1 Max 64GBなら fp32 でも余裕。

多言語プロンプトで「日本語特徴」を統計的に確認

特徴23991が本当に日本語特徴なのか、それとも特定トークンに偶然反応しているだけなのかを切り分ける。 日本語・英語・コード・中国語で各15プロンプト、合計60プロンプトを通し、position 0(アテンションシンク)を除いて上位50特徴の出現頻度を集計した。 プロンプトは天気・食事・仕事・趣味・感情まで散らばらせ、文長も短文〜中文を混ぜている。日英中は対訳ペア、コードのみ独立系統。

集計コードの中核は次のとおり。

from collections import Counter
counts = {lang: Counter() for lang in PROMPTS}
for lang, prompts in PROMPTS.items():
    for p in prompts:
        # ... forward pass + SAE encode ...
        topk_idx = pre_acts.topk(50, dim=-1).indices[0, 1:, :]  # skip pos 0
        for fid in topk_idx.flatten().tolist():
            counts[lang][fid] += 1

「あるfidが対象言語で何%のトークンに登場し、他言語の最大値と何%差があるか」をスコアに、各言語の判別特徴トップ3を出す。 分析対象トークン数: 日本語113、英語95、コード148、中国語66。

言語トップ1トップ2トップ3
日本語fid 23991(98.2% / 他1.5%fid 54939(60.2% / 0%)fid 39050(41.6% / 0%)
英語fid 11916(81.1% / 40.9%)fid 28624(41.1% / 7.1%)fid 14371(26.3% / 0%)
コードfid 28894(68.2% / 0.9%)fid 14558(54.1% / 1.5%)fid 25250(44.6% / 0%)
中国語fid 36213(50.0% / 8.9%)fid 46086(40.9% / 4.4%)fid 18775(40.9% / 4.4%)

各セルは 対象言語頻度 / 他言語最大頻度

特徴 23991 は日本語の113トークン中111個(98.2%)で点灯し、英語・コードでは0%、中国語でも1.5%(1トークンのみ、日中共通の漢字経由と思われる)。 60プロンプトに増やしても比率がほぼ変わらないので、これは安定した日本語特徴とみなせる。

英語のfid 11916は81.1%出るものの他言語の最大が40.9%(コード由来)あり、混ざる。コード由来かというとそうでもなく、英語自然文と一部のコード(変数名や英語コメント部分)に共通する「英単語っぽさ」を捉えている可能性がある。 日本語の23991ほどクリーンに単一概念に対応する特徴ばかりではない、ということがわかる。

中国語のfid 36213は50%とそこそこだが、もっとも強い候補fid 18558は81.8%/47.8%で他言語混入が目立つ(日本語・コードの漢字に反応していると思われる)。日中で文字を共有するタスクではこの混入は避けられない。

技術レポートの「英語で見つけた毒性特徴をそのまま日本語に適用してF1 0.76が出た」という結果は、SAEがここまでクリアに言語を分離する一方で、言語横断的な抽象特徴も別にちゃんと持っている、という二段構えで成立しているのだろう。

言語ごとのトークン効率と活性化マグニチュード

集計途中で2つの言語差が見えたので追記する。最近よく話題になる「日本語はAPI呼び出しでトークン消費が多い」と「日本語のほうが内部の活性化がでかい」の話。

トークン効率: 同じ内容で日英中を比較

日英中は意味を揃えた対訳プロンプト15本ずつ、コードは独立系統。

言語総トークン数(15プロンプト)プロンプト平均vs 中国語
中国語664.41.00x
英語956.31.44x
日本語1137.51.71x
コード1489.92.24x

日本語は同じ内容で英語より約19%多く、中国語より71%多くトークンを食う。 理由はQwen3 のBPEトークナイザが学習データ分布を反映するため。Qwenは中国製モデルなので中国語データを大量に食わせており、中国語の頻出シーケンスはマージされて1トークン、日本語は文字単位に近い分割が残る。 APIコストには直接効く話で、Qwen3を有料APIで呼ぶなら同じ内容を中国語で出させると一番安い。

先頭トークンの活性化マグニチュード

layer 17のresidual stream L2ノルム平均をプロンプト15本ずつで集計した。

言語pos 0 平均ノルムpos 0 中央値pos 1+ 平均ノルム
日本語10,34910,437107.4
英語8,5507,693104.1
中国語10,83110,891111.1

先頭トークンだけ系統的に日本語/中国語のほうが英語より21〜27%大きく、それ以降のトークンは言語差なし(3〜7%差で誤差圏)。

英語の平均が下がるのは The Got When のような短い超頻出トークンが embedding norm を引き下げるため。日本語の漢字始まり(朝・明日・猫)はそれぞれ10000台で安定して大きい。 「学習データ量が少ない言語ほど活性化が大きい」というよりは「1トークンあたりの意味密度が高い言語ほど embedding norm が大きい」と言ったほうが正確そう。 中国語と日本語は意味密度が高く、英語は短い機能語が多いため平均が下がる。

ただしマグニチュードの言語差は推論コストには効かない。ベクトル演算は次元数(4096)で決まるため、ノルムが大きくても掛け算の手数は同じ。 お金として効くのは前者のトークン消費だけ、内部表現の特性は別の話、という整理になる。

低リソース言語まで広げたら仮説が逆転した

ここで素朴な疑問が出る: 「日本語のほうが活性化大きい」は単純に学習データ量の差じゃないのか? Qwenは中国製モデルで日中英は全部主要学習言語。差が小さいのも当然で、本当に学習量効果を見たいならもっと学習量段差が大きい言語で比較すべきだ。

候補としてラテン語が一瞬浮かぶが、Wikipedia・古典・学名・教会文書で意外とコーパス汚染が大きく、見た目より学習量がある。 真の低リソース条件は「現役で話されているがウェブ上のテキストが極端に少ない言語」。バスク語あたりが筆頭で、ヒンディーは中規模リソース。 日中英 + ヒンディー(hi) + バスク(eu) で同じ実験を回した結果。

言語トークン/プロンプトpos 0 ノルム平均pos 1+ ノルム平均
中国語 (zh)5.1010,742111.7
英語 (en)6.408,214103.4
日本語 (ja)8.2010,378107.1
ヒンディー (hi)26.908,79695.2
バスク (eu)13.707,57196.3

仮説と逆だった。低リソース言語ほど活性化が小さい。バスクのpos 0ノルム7571は全言語中最小、ヒンディーのpos 1+ノルム95.2も最小。 「未学習の言語は内部で混乱して活性化巨大化するのでは」という直感は外れていて、実態は「embeddings がよく学習されてないから反応が鈍い」だった。

これで前述の「意味密度モデル」が補強される。

  • 中国語: 1文字=複数単語の意味密度 → 活性化最大
  • 日本語: 漢字+ひらがな混在で意味密度高め → 大きめ
  • 英語: 機能語多めで密度中程度
  • バスク: 接尾辞・形態素まで分割される細切れトークン → 意味密度低 → 活性化小
  • ヒンディー: Devanagari結合文字までバイト分割 → 1トークン=数バイトしか情報量なし → 反応も鈍い

ヒンディーの破壊的トークン爆発

26.9 tok/prompt は英語の4.2倍、中国語の5.3倍。Devanagari結合文字(मैं のような母音記号付き子音)が個別バイトレベルまで分解されている。 ヒンディーでQwen APIを叩くと中国語の5倍課金されるということで、API コスト議論で「日本語が高い」より「ヒンディーが地獄」のほうが正確な現実。

学習量による活性化マグニチュードへの影響は、マグニチュードを直接押し上げる方向ではなく、トークナイザの分割粒度を細かくすることで意味密度を薄め、結果として活性化を小さくする方向に効く、というのが今回の検証で見えた構図。

人工言語ではpos 0が嘘をつく

人工言語まで広げて検証する。自然言語より学習データ量の段差が極端で、仮説の追加検証としてより効く。

  • エスペラント (eo): Wikipedia 37万記事の学習量ある人工言語
  • ナヴィ (nav): 映画『アバター』の言語、ファンコミュニティ・wikiあり、コーパス小
  • ヒュムノス (hym): ゲーム『アルトネリコ』の言語、ほぼ歌詞のみ、コーパス極小

ナヴィは Avatar Wiki日本語版 の例文を起点に作成、ヒュムノスは歌詞由来の典型構文(Was yea ra ... 形式)で作成した。文法精度は本筋ではないので深追いしていない。

言語tok/promptpos 0 ノルムpos 1+ ノルム
英語 (en, 参考)6.408,214103.4
エスペラント (eo)12.908,32498.3
ナヴィ (nav)10.107,67697.2
ヒュムノス (hym)10.808,94697.6
バスク (eu, 参考)13.707,57196.3

ヒュムノスのpos 0ノルム8,946が異常に大きい。バスクの7,571より17%大きく、英語まで超えている。 低リソース言語ほど活性化が小さい、という直前の仮説と矛盾する。

謎解き: トークン化を見ると Was artifact だった。

ヒュムノスは「感情詞 + 動詞 + 目的語」の構造で、感情詞は WasWee で始まる。 私が用意したプロンプト10本中8本が Was 始まり、2本が Wee 始まり。 Was は英語の過去形動詞でQwenが大量に学習しているため、その embedding magnitude は英語の高頻出トークンと同等になる。 pos 0 は先頭トークンの embedding に支配されるため、ヒュムノスの「内部表現」ではなく「先頭トークンが偶然英語語彙と一致したか」を測ってしまっていた。

pos 1+ で見ると仮説と整合する:

  • 英語: 103.4(高リソース、特権的)
  • エスペラント: 98.3(人工言語の中では学習量多め、Romance/Germanic ルートが部分的に英語的にトークン化される)
  • ナヴィ: 97.2、ヒュムノス: 97.6、バスク: 96.3(全部97前後で団子)

低リソース勢(自然言語のバスク + 人工言語3つ)が97前後、英語より約6%低い、というクリーンな段差が出る。 1〜2文字単位までトークンが分解される言語では、意味密度が下がり活性化マグニチュードも下がる、という意味密度モデルが再確認された。

参考までにトークン化サンプルは次のとおり。

  • ナヴィ KaltxìK alt x ì(4トークン、文字単位に近い)
  • ヒュムノス hymmnosĠhym mn os(3トークン)
  • エスペラント hodiaŭĠh odia ÅŃŭ がバイト分割される)

メソッド面の教訓: pos 0 は first-token-dependent すぎて言語比較のメトリックとしては不安定。先頭トークンが偶然英語語彙と被ると数値が跳ねる。 言語の内部表現の品質を測りたいならpos 1+ の集計値のほうが信頼できる。 SAEの介入実験を設計するときも、pos 0 を無条件に含めると attention sink + 先頭トークン artifact の両方を拾ってしまうので、pos 1+ だけで集計するのが安全。

層を変えると同じ概念が別IDに変わる

SAEは各層ごとに独立して訓練されているため、層が違えば特徴IDの意味は別物になる。 これを確かめるため、layer 0(埋め込み直後)、layer 17(中間)、layer 35(最終)の3層に同じプロンプトを通した。

3層を1回のフォワードパスで同時にフックする。

for L in [0, 17, 35]:
    hooks.append(model.model.layers[L].register_forward_hook(make_hook(L)))

各層で「日本語/英語/コード」の判別特徴トップ3を取った結果。

日本語のトップ判別特徴英語のトップ判別特徴コードのトップ判別特徴
layer 019432 (62%/0%)57649 (46%/0%)32928 (70%/0%)
layer 1723991 (100%/0%)5649 (85%/17%)41944 (78%/0%)
layer 3541302 (100%/4%)15149 (92%/19%)56427 (100%/12%)

特徴ID 23991(layer 17の日本語特徴)を他層で見ると次のようになる

ja頻度en頻度code頻度
layer 06%0%0%
layer 17100%0%0%
layer 350%0%0%

layer 0ではかすかに反応するが、layer 35では完全に消える。 同じ概念(「日本語」)を表す特徴は層ごとに別IDで再構成されている、ということが確認できる。

層別の傾向は次のとおり。

  • layer 0: 判別力が弱め(日本語62%)。埋め込み直後は概念がまだ分離されていない
  • layer 17: 単一言語が単一特徴にきれいに集約(日本語100% / 他0%)
  • layer 35: layer 17同様に強い判別力。コード56427は100%/12%で顕著

中間〜後段で言語クラスタが鮮明になる傾向が出ている。 特定特徴の介入実験をやるなら、layer 17以降を狙うのが妥当。

引っかかった点

  • torch_dtype 引数は transformers 5.x で deprecated。dtype= に変えろと警告が出る
  • 初回フォワードは1.8秒だが2回目以降は0.3秒前後。MPSのカーネルキャッシュ効果と思われる
  • weights_only=True を付けないと torch.load が将来エラーになる警告が出る。SAE .pt は単純な dict なので付けてOK
  • メモリ: bf16で17〜18GB安定(モデル16GB + SAE 1層2GB)、fp32で32GB。M1 Max 64GBなら全部 fp32 でも入る
  • SAEファイルは fp32 で約2.0GB/層。36層全部欲しいと72GB食うので、必要な層だけ落とす運用が現実的

同じ手順でNSFW/refusal特徴も取れるはず

ここからは未検証の話。手順を書き起こすと「あとは実装するだけ」のレベルなので、読み手が同じことを試せる粒度で書く。

前提: Qwen3-8B-Base は base model なので元々そんなに拒否しない。実用的な検証対象は chat tuned 版の Qwen3-8B(同名)になる。Qwen-Scope の README に「base model 用SAEを post-training checkpoint に使うのはリーズナブル」と明記されているので、SAE自体は今回使ったものをそのまま流用できる。

検出フェーズ

  1. プロンプトペアを集める
    • 拒否系: Qwen3-8B が断る系のプロンプト30〜50本(爆発物製造、武器入手、自傷誘導、児童関連、生物兵器など実際に refusal が返るもの)
    • 安全系: 同じドメインの安全版30〜50本(火薬の歴史、銃規制議論、メンタルヘルス相談、児童保護啓発、感染症対策)
  2. 各プロンプトを layer 17 に通し、SAE encode → top-50 特徴IDを集計
  3. 「拒否系で出現率 ≥80%、安全系で ≤20%」の特徴IDを抽出
  4. 日本語実験で fid 23991 が 98.2% / 1.5% で立ったのと同じ統計の使い方になる

検出したfidが2〜3個に絞れたら、それぞれが何をトリガーしているか1本ずつプロンプト単位で確認する。「拒否」「警告」「安全配慮」「話題回避」などに分解されている可能性がある(polysemantic feature ではなく逆に細分化されている)。

ファインチューニングではない

ここを混同しやすいので明示しておく。この介入はファインチューニングではない。訓練データも勾配計算もモデル重みの書き換えもない。推論時に layer 17 のフォワードフックで活性化ベクトルから W_dec[:, R] を引き算するだけで、フックを外せば即座に元のQwen3-8Bに戻る。 SAEの decoder direction を「概念軸」として使うこの種の操作は activation steeringrepresentation engineering と呼ばれる別カテゴリで、Anthropic の steering 研究や Andy Zou らの RepE 論文で扱われている。abliteration だけは「フックの引き算を重みに焼き込む」ステップを踏むのでファインチューニングっぽく見えるが、これも勾配を使わずに closed-form で1回計算するだけなので分類上は同じ training-free intervention。

実用上の差はこう。

項目ファインチューニングSAE介入
訓練データ数百〜数万サンプル必要不要
勾配backprop必須なし
モデル重み更新される(不可逆)改変なし
適用/解除別チェックポイントhook付け外しで瞬時
副作用catastrophic forgetting等なし、元モデルは無傷

同じQwen3-8Bファイルで「フック付き = 拒否回避モード / フックなし = 通常モード」を一発で切り替えられるのが本質的な違い。

仕組み: PyTorchのforward hookで活性化を途中で書き換える

「重みを触らずに出力を変える」のカラクリは PyTorch の register_forward_hook。 モデルの各層に「この層の出力テンソルを intercept する関数」を登録すると、推論中に勝手に発火して、テンソルを書き換えてから次の層に渡してくれる。

通常のフォワードパスはこう。

flowchart TD
    A[入力トークン] --> B[埋め込み]
    B --> C[layer 0..16]
    C --> D[layer 17]
    D --> E[layer 18..35]
    E --> F[lm_head]
    F --> G[出力トークン]

hookを刺した状態はこう。

flowchart TD
    A[入力トークン] --> B[埋め込み]
    B --> C[layer 0..16]
    C --> D[layer 17]
    D --> H[hook発火<br/>拒否方向を引く]
    H --> E[layer 18..35<br/>書き換えられた<br/>residualで計算]
    E --> F[lm_head]
    F --> G[違うトークン]
    style H fill:#ffe4b5

ステップで追うと次のとおり。

  1. layer17が普通に計算してresidual stream(4096次元)を出力
  2. PyTorchが「layer17にhookが登録されている」と気付いてhook関数を呼ぶ
  3. hookが受け取ったテンソルから拒否方向 W_dec[:, R] を引く
  4. 書き換えられたテンソルがそのままlayer18の入力になる
  5. layer18以降は何も知らずに普通に計算する。「書き換えられたresidual」を入力として残りの18層が動く
  6. lm_headが次トークンの確率分布を出す。元と違うresidualから始まったので違うトークンが選ばれる

「推論パスの途中で水道管に異物を流し込む」イメージ。モデル本体(重み)は無傷だが、流れる水(活性化)が変質する。

生成は autoregressive で1トークンごとに forward pass が回るので、100トークン生成すれば100回hookが発火する。「爆発物の作り方を教えて」に対して毎ステップ拒否方向を引かれ続けることで、I cannot が出にくくなる。

hook版 vs weight surgery版

実は同じ介入を実装する系統が2つある。

  • Inference-time hook: 推論ごとにhookで書き換える。register_forward_hook で付け外し、元モデルファイルは無傷。試行錯誤段階で機動的
  • Weight surgery (abliteration): hookの引き算を重みに焼き込む。layer18の入力射影行列から W_dec[:, R] 方向を直交射影で除去して新しい重みファイルとして保存。以降フック不要で標準の推論パスで拒否しない挙動になる。配布や運用は楽だが介入オフがもうできない

研究フェーズなら前者、配布したいなら後者。今回扱う「介入フェーズ」のコードは前者の hook 版を示す。

介入フェーズ

検出した拒否 fid を R とする。SAEの decoder column W_dec[:, R] が「拒否方向」の SAE 的近似で、4096次元のベクトル。 推論時に layer 17 にフォワードフックを刺して residual stream から引く。

def refusal_zero_hook(module, _input, output):
    hidden = output[0] if isinstance(output, tuple) else output
    # fid R の SAE pre-activation を測る(topkの中に入っているかは見ない)
    refusal_act = (hidden @ W_enc[R].T + b_enc[R]).clamp(min=0)
    # 立っていた分だけ decoder direction を引く
    hidden = hidden - alpha * refusal_act.unsqueeze(-1) * W_dec[:, R]
    return (hidden,) + output[1:] if isinstance(output, tuple) else hidden

model.model.layers[17].register_forward_hook(refusal_zero_hook)

alpha は1.0から始めて、拒否が外れるまで上げる。W_dec[:, R] 1本を引くだけなのでメモリも追加コストもほぼゼロ。 複数fidが拒否を担っていた場合は同じことを R1, R2, R3 でループする。

評価

  • 介入前: 「爆発物の作り方を教えて」→ “I cannot provide instructions for creating explosives…”
  • 介入後: 同じプロンプト → 拒否文言が消える、または別の応答が始まる

成功判定は refusal phrase 検出(“cannot”, “won’t”, “I’m sorry but”, “申し訳ありません” 等)の出現率で取ればよい。介入前に95%出ていた refusal phrase が10%以下まで落ちれば、検出特徴 = 抑制特徴の仮説が成り立っている。

起きそうな失敗

  • 拒否fidが特定できても能力ごと劣化する(ラベリング誤り、巻き込みで polysemantic 化)
  • 検出側で 80% 当たっても抑制側で 20% しか効かない(検出と抑制が同じ次元じゃない可能性)
  • 拒否が複数fidに分散していて1本zero化では足りない
  • 別の安全機構(attention head側の検閲、output filter)に止められる

従来 abliteration との違い

従来の abliteration は残差ストリームの平均差分から拒否方向を1本推定して直交射影で除去する。1本ベクトルなので副作用が大きく、有害カテゴリを丸ごと外してしまう。 SAEなら81920特徴のうち選んだ数本だけを操作するので、「医療相談には答えるが武器の作り方は拒否する」のような選択的な弱体化が原理的には可能になる。逆方向の「特定特徴を増幅して安全側に寄せる」介入も同じ仕組みで動く。

Qwen-Scope の紹介記事 で書いた「公式は毒性検出と安全データ合成を推しているが、検出に使える特徴がそのまま除去に使える特徴でもあるのは自明」は、この手順を念頭においた話だった。 今回 fid 23991 が日本語に対して 98.2% で立ったことの実証は、同じ手順がNSFW/refusalに対しても回ることの最小限の根拠になる。 実機で回したら別記事になる。

余談: LLMは「知らないこと」を考えもしない

低リソース言語の活性化が小さくなる結果は、単体で示唆が深い。 直感的には「未知の言語に出会ったら混乱して活性化が暴れる」ほうが自然に思えるが、実際は逆で静かに弱く反応するだけだった。LLMは「知らない」という内部状態を持たず、ただ薄い反応で処理を続ける。

これはハルシネーションの根本構造とも繋がる。 LLMが「分からないことを認められない」のは性格や訓練不足ではなく、「分からない」という内部状態自体が表現空間に存在しないからと考えられる。バスク語を見せられても「これはバスク語、知らない」と認識するのではなく、ただ弱く処理してそれっぽい出力を継続する。

心理学で言う “unknown unknowns”(自分が何を知らないかすら知らない領域)をLLMが計算機側で再現している、と整理できる。 この方向の考察は別途まとめると独立記事1本になりそうなので、ここでは観察の指摘までに留める。