Skip to content

Instantly share code, notes, and snippets.

@pokutuna
Last active March 2, 2026 03:02
Show Gist options
  • Select an option

  • Save pokutuna/f1d59d4e9aa3f03c6fdb7e480f67d412 to your computer and use it in GitHub Desktop.

Select an option

Save pokutuna/f1d59d4e9aa3f03c6fdb7e480f67d412 to your computer and use it in GitHub Desktop.

LLM2025メインコンペ: SFT/DPO なしに基準点を超える試み

この課題は...プロンプトチューニングで解ける!!

最終課題のメインコンペは、Qwen3-4B-Instruct-2507 をベースに構造化出力 (JSON / YAML / TOML / XML / CSV) の生成精度を競うものです。入力に対して、指定されたフォーマットでデータを生成・変換し、構文の正しさとデータの内容で評価されます。

SFT や DPO でモデルの重みを更新して精度を上げるのが本来の解き方ですが、あえてこれらを行わず プロンプトチューニングで合格点ラインを超える試みを紹介します。

実際にこの方法で LB スコア 0.756 を達成できました。

リーダーボードスコア

どこでプロンプトチューニングできるのか?

「どこでプロンプトチューニングするんだ、標準提出コードを改変しちゃダメだろう」と思うかもしれません。

実は標準推論コードをよく読むとフックポイントがあります。
LoRA を利用した際の adapter_merge ではベースモデルのトークナイザを利用していますが、
base, merged であれば、指定した HuggingFace 上のモデルのトークナイザがそのまま使われます。

# 0214改修版_2025最終課題メインコンペ_標準コード2(提出JSON生成).ipynb より引用

def resolve_model_path():
    if MODEL_SOURCE == "base":
        return BASE_MODEL_ID_OR_PATH
    if MODEL_SOURCE == "merged":
        return MERGED_MODEL_ID_OR_PATH  # ← このモデルのトークナイザが使われる
    if MODEL_SOURCE == "adapter_merge":
        ...  # ベースモデルのトークナイザを使用

model_path = resolve_model_path()
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

for item in pub:
    messages = [{"role": "user", "content": item.get("query", "")}]
    prompts.append(tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True))

ここでのポイントは、

  • merged モデルとして提出する場合、参加者がアップロードした tokenizer がそのまま使われる
  • tokenizer.apply_chat_template(...) の結果が vLLM に送られる

つまり、トークナイザに埋め込まれたチャットテンプレートを変更すればプロンプトを制御できるということです!!

チャットテンプレートとは

チャットテンプレートとは、Hugging Face の tokenizers / transformers ライブラリにおいて、チャット形式のメッセージ ([{"role": "user", "content": "..."}, ...])をモデルが期待する形式に変換するためのテンプレートです。

LLM への入力形式はモデルごとに異なり、チャットの開始・終了を示す特殊トークンやメッセージの区切りにはそれぞれ独自の形式があります。
例えば [{"role": "user", "content": "Hello!"}] という同じメッセージを、各モデルは以下のように展開します:

Qwen3

ChatML 形式、OpenAI が ChatGPT の内部フォーマットとして定義したもの。

<|im_start|>user
Hello!<|im_end|>
<|im_start|>assistant

https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507/blob/main/tokenizer_config.json#L229

Llama 3

<|begin_of_text|><|start_header_id|>user<|end_header_id|>

Hello!<|eot_id|><|start_header_id|>assistant<|end_header_id|>

https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/blob/main/tokenizer_config.json#L2053

Gemma 3

<start_of_turn>user
Hello!<end_of_turn>
<start_of_turn>model

https://huggingface.co/google/gemma-3-4b-it/blob/main/tokenizer_config.json

DeepSeek V3

全角パイプ や下線 を使った独特なやつ。

<|begin▁of▁sentence|><|User|>Hello!<|Assistant|>

https://huggingface.co/deepseek-ai/DeepSeek-V3/blob/main/tokenizer_config.json#L34

gpt-oss

OpenAI が gpt-oss で採用した ChatML の後継 Harmony 形式

<|start|>user<|message|>Hello!<|end|>
<|start|>assistant<|channel|>final<|message|>

https://huggingface.co/openai/gpt-oss-20b/blob/main/chat_template.jinja

このようにモデルごとに形式はバラバラです。
ここでは見ていませんが、system プロンプトや、tool calling 回りもモデルによって表現が異なります。

チャットテンプレートは、メッセージのリストと各モデル固有の入力形式の間の変換レイヤとして機能しており、このおかげでモデルの詳細を知らずともチャット形式のデータを入力することができます。

テンプレートは Jinja2 で記述され、上記のリンクのように、大抵 tokenizer_config.jsonchat_template フィールドに格納されています。tokenizer.apply_chat_template(messages) を呼ぶと、このテンプレートが適用されてモデル固有の形式に変換されます。

参考リンク

余談1: Qwen3 の thinking モードの切り替え

Qwen3 の thinking モード (<think>...</think>) の OFF もチャットテンプレートで実現されています。何も指定しなければ生成の冒頭で <think> が出力され思考内容が始まりますが、enable_thinking=False を指定すると、テンプレートが空の <think> ブロックを挿入し、思考済みの状態からデコードを開始させ、続く解答が直接出力されるよう促します:

{%- if add_generation_prompt %}
    {{- '<|im_start|>assistant\n' }}
    {%- if enable_thinking is defined and enable_thinking is false %}
        {{- '<think>\n\n</think>\n\n' }}
    {%- endif %}
{%- endif %}

https://huggingface.co/Qwen/Qwen3-32B/blob/main/tokenizer_config.json

余談2: Jinja2 の機能を使いすぎると困る

Jinja2 は元々 Python の Web フレームワーク向けのテンプレートエンジンですが、LLM のチャットテンプレートの事実上の標準になったことで、Python 以外の実行環境でもこの実装を再現する必要に迫られています。

vLLM は Jinja2 をそのまま利用しており、--chat-template でカスタムテンプレートを指定できます。リポジトリに各モデル向けのテンプレートが含まれていて、管理が面倒そうですね。

llama.cpp は C++ で Jinja2 を再実装した minja を内蔵し、主要モデルのテンプレートとの互換性をテストしているようです (chat.cpp)。

Ollama は推論エンジンとして llama.cpp を利用していますが、テンプレート処理は Go の text/template で独自に実装しており、Jinja2 との互換性がありません。ollama/ollama#10222

Jinja2 前提でテンプレートを書くと他の環境での実行に支障が出たり、移植で面倒が起きがちなので、実際の現場では本稿のようなことはしないほうが良いでしょう。

OpenRouter や DeepInfra などのオープンモデルの API プロバイダはサーバー側でテンプレートを持っていますが「これ設定間違ってるんじゃね?」と思うことが稀にあり、気を付けて使う必要があります。

Chat Template をタスクに合わせてカスタマイズする

話をメインコンペ課題に戻しましょう。

チャットテンプレートは Jinja2 テンプレートなので、メッセージの内容に応じて動的に条件分岐やテキスト加工が自由にできます。 つまり、入力されるタスクのプロンプトに応じて、モデルに追加で指示を与えることができます

今回のタスクでは、大抵クエリの1行目にターゲットのフォーマット名が含まれていることを利用し、

  • Please output TOML code: → TOML 形式で出力
  • Please convert the following CSV to YAML code: → YAML 形式で出力

これを検出して、フォーマットごとに異なる指示や例を挿入するテンプレートを作ります:

{%- for message in messages %}
    {%- if message.role == "user" %}
        {%- set content = message.content %}
        {%- set suffix = '' %}
        {# 1行目の末尾のフォーマット名を検出 #}
        {%- set fl = content.split('\n')[0].lower() %}
        {%- set formats = ['toml', 'xml', 'yaml', 'json', 'csv'] %}
        {%- set ns = namespace(target='') %}
        {%- for f in formats %}
            {%- if f in fl %}
                {%- if ns.target == '' or fl.rfind(f) > fl.rfind(ns.target) %}
                    {%- set ns.target = f %}
                {%- endif %}
            {%- endif %}
        {%- endfor %}
        {# フォーマットごとに指示を追加 #}
        {%- if ns.target == 'toml' %}
            {%- set suffix = '\n\nOutput ONLY raw TOML. Use [table] for ...' %}
        {%- elif ns.target == 'xml' %}
            {%- set suffix = '\n\nOutput ONLY raw XML. Escape & as &amp; ...' %}
        {%- elif ns.target == 'yaml' %}
            {%- set suffix = '\n\nConvert to valid YAML following ...' %}
        {%- endif %}
        {# suffix 付きでメッセージを出力 #}
        {{- '<|im_start|>user\n' + content + suffix + '<|im_end|>\n' }}
    {%- endif %}
{%- endfor %}

例えば以下の入力を行うと、

[{"role": "user", "content": "Please convert the following JSON to TOML code:\n{\"name\": \"Alice\", \"age\": 30}"}]

TOML 向けの指示が追加されて以下のようなプロンプトが生成されます:

<|im_start|>user
Please convert the following JSON to TOML code:
{"name": "Alice", "age": 30}

Output ONLY raw TOML. Use [table] for nested objects and [[array]] for arrays. Do not use inline tables.
<|im_end|>
<|im_start|>assistant

Chat Template でプロンプトチューニングする

まずベースモデル Qwen3-4B-Instruct-2507 の出力を確認し、フォーマットごとの傾向を分析しました。
CSV と JSON はベースモデルで十分に解けていそうなため、苦手とする TOML / XML / YAML の 3 フォーマットに追加の指示を適用することにしました。

各フォーマットに与える指示文は、DSPy の GEPA で最適化しました。
学習データをサンプリングして評価基準を作成し、自己評価スクリプトでフィードバック(パースエラーの内容、マッチしたパス・欠けたパスの具体例)を与えるようにして改善を回します。

フィードバック例:

Syntax OK.
Matched 12/15 paths.
Missing: ['config.servers[0].port', 'config.servers[0].host', ...]

このフィードバックを読み改善プロンプトを提案する GEPA の教師側のモデルには OpenRouter 上の Qwen/Qwen3-235B-A22B を利用しました。ルールで LLM による学習データの生成は禁止されていますが、ここで生成しているのはプロンプトでありモデルの学習データには当たらないと解釈して利用しています。グレーなラインだと思いますが、本稿の面白さ優先と、LB の提出版に利用していないことでご容赦ください。

このようにして 3 つの出力フォーマットについて改善を行い、最終的にフォーマットごとの追加の指示文を chat template に統合しました。

(具体的なプロンプトはそのまま解法になるので割愛しています、のちほど追記するかもしれません)

モデルに "何かしらの変更" を加える

コンペルールでは「提出するモデルには必ず何かしらの変更を加える必要がある」とされています。

  • 提出するモデルには必ず何かしらの変更を加える必要があります
  • SFTやRLHF、DPOなどによりパラメータを更新すればその条件を満たします
  • 量子化も変更にあたります

このルールを遵守しつつ、チャットテンプレートの変更のみで提出して結果を確認したいです。なので lm_head の末尾 (未使用トークン) の weight を +1e-4 だけ変更しました。文句なく何かしらの変更が加わったと言えるでしょう。

last_idx = model.lm_head.weight.shape[0] - 1  # 151935 (どのトークンにも割り当てられていない)
model.lm_head.weight.data[last_idx, 0] += 1e-4

提出

こうしてモデルの重みを微小に変更し、最適化した chat template を設定し Tokenizer を保存して HuggingFace にアップロードしました。

提出した結果、リーダーボードスコア 0.756 となり、0.7 の基準点を上回ることができました。

(このモデルはスコアの確認のみの目的で提出しており、最終版として別途ちゃんと学習したモデルを提出して上書きしています)

まとめ

  • チャットテンプレートは messages 形式とモデル固有の入力形式の間の変換レイヤであり、Jinja2 で記述されている
  • テンプレートはモデルの Tokenizer に同梱されるため、標準推論コードを変更せずにプロンプトを制御できる
  • テンプレート内で条件分岐を使い、フォーマット固有の指示を自動挿入することでスコアを改善できた
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment