10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

松尾研 LLM開発コンペ2025 予選1位モデルの学習方法について

10
Last updated at Posted at 2025-10-27

はじめに

私達のチームは 松尾研 LLM開発コンペ2025 の予選で1位となりました。本記事では、その際に用いたモデルの学習方法をまとめます。

予選での戦略

予選では、モデルの性能を上げることよりも、もともと HLE のスコアが高いモデルを忘却を防いで学習し、そのまま提出することを重視しました。

この戦略をとった理由

予選で使えるモデルの中で最もスコアの高い DeepSeek-R1-0528 を学習・推論するのは非常に難しいため、それだけで勝てる見込みがあったからです。

決勝で分かったこと

決勝を通じて明らかになったのは、上記の戦略に基づいて開発した、なるべく忘却せずに学習する手法が、HLE に対するスコアを再現性をもって向上させられるという点です。

HARIチューニングについて

当初このチューニングを、よく分からないため 「謎チューニング」 と呼んでいましたが、その性能を認め、現在は 「HARIチューニング」 と呼んでいます。
HARIチューニングの詳細は別のレポートでご紹介する予定です。

本記事で紹介する内容

今回は、予選モデルの学習に用いたスクリプトをご紹介します。

1. 環境構築

以下のcondaを作成してください。
Condaバージョン: Miniconda 24.7.1
Python: 3.11

# Create a new conda environment with Python (you can specify the version you need)
conda create -n deepseek_qlora_clean python=3.10

# Activate the environment
conda activate deepseek_qlora_clean

# PyTorch/cu121
pip install --no-cache-dir --force-reinstall \
  torch==2.5.1+cu121 torchvision==0.20.1+cu121 torchaudio==2.5.1+cu121 \
  --index-url https://download.pytorch.org/whl/cu121

# bnb/triton/accelerate/transformers/peft を固定(依存を引かない)
pip install --no-cache-dir --force-reinstall --no-deps \
  triton==3.1.0 bitsandbytes==0.43.1 accelerate==0.24.1 transformers==4.36.0 peft==0.6.0

pip install huggingface_hub

# 不足している依存パッケージをインストール
pip install --no-cache-dir \
  regex \
  psutil \
  safetensors \
  tokenizers==0.15.2 \
  datasets \
  scipy

# または、特定バージョンで固定したい場合
pip install --no-cache-dir \
  regex!=2019.12.17 \
  psutil \
  safetensors>=0.3.1 \
  tokenizers==0.15.2
  
 # Pythonとcudaバージョンに合うwheelを直接インストール
pip install https://github.com/Dao-AILab/flash-attention/releases/download/v2.6.3/flash_attn-2.6.3+cu123torch2.3cxx11abiFALSE-cp310-cp310-linux_x86_64.whl

# Set environment variables
export LD_LIBRARY_PATH="$CONDA_PREFIX/lib:$LD_LIBRARY_PATH"
export BNB_CUDA_VERSION=121

2. HARI

2.1. ノード確保

srun --partition P10 --nodes=1 --gpus-per-node=8 --mem=0 --cpus-per-task=240 --time=08:00:00 --pty bash -i

2.2. condaアクティベート

conda activate deepseek_qlora_clean

2.3. 環境変数設定+ディレクトリ作成

export NVME_DIR=/nvme12/$USER
export TMPDIR=$NVME_DIR/tmp
export HF_HOME=$NVME_DIR/hf_home
export TRANSFORMERS_CACHE=$NVME_DIR/hf_cache
export CUDA_CACHE_PATH=$NVME_DIR/ComputeCache
mkdir -p "$TMPDIR" "$HF_HOME" "$TRANSFORMERS_CACHE" "$CUDA_CACHE_PATH"

2.4. config

LAMBDA_KL=0.9にして忘却防ぐのがポイントです。これがHARIです。

cat > qlora.env <<'ENV'
# ===== QLoRA runtime config (.env) =====
MODEL_LOCAL_DIR=/nvme12/P10U001/deepseek_src
NVME_DIR=/nvme12/$USER

# HF private datasets 用
HF_TOKEN=

# 乱数シード
SEED=42

# 学習ノブ
BS=2
EPOCHS=1
GRAD_ACCUM=2
MAX_LEN=4096
CLIP_NORM=1.0
WARMUP_RATIO=0.03
LOG_INTERVAL=100
GRAD_CP=1

# 参照KL正則化(0で無効)
LAMBDA_KL=0.9
KL_T=1.5

# ===== データセット設定(新規) =====
# 方式A: 文字列で簡易指定
#   repo[:limit][@split+split], 複数はカンマ区切り。limit=ALL/未指定で全件。
SFT_DATASETS=oNo-1/MedMCQA:ALL@train

# 方式B: JSONで厳密指定(Aよりこちらが優先)
# SFT_DATASETS_JSON='[{"name":"oNo-1/MedMCQA","max_n":null,"splits":["train"]},{"name":"oNo-1/OlympiadBench","max_n":8000,"splits":["train"]}]'

# 方式C: 総件数の上限(全体カット)。未指定なら無制限
# SFT_LIMIT=12000

# デフォルトで試す split(個別指定がなければこれを使用)
SFT_SPLITS=train,validation,test

# ===== Config loader の振る舞い =====
QLORA_ENV_OVERRIDE=1
ENV

2.5. スクリプト

cat > train_qlora.py <<'PY'
import os, re, torch, math, json
from datetime import datetime
try:
    from zoneinfo import ZoneInfo
    JST = ZoneInfo("Asia/Tokyo")
except Exception:
    JST = None

from transformers import (
    AutoConfig, AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, DataCollatorForLanguageModeling,
    get_linear_schedule_with_warmup
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from bitsandbytes.optim import PagedAdamW8bit
torch.backends.cuda.matmul.allow_tf32 = True
import torch.nn.functional as F  # KL

# ========== Config loader (.env / JSON) ==========
def _load_env_from_file(path, override=False):
    def _strip_inline_comment(s: str) -> str:
        in_single = False; in_double = False; out = []
        for ch in s:
            if ch == "'" and not in_double: in_single = not in_single
            elif ch == '"' and not in_single: in_double = not in_double
            elif ch == '#' and not in_single and not in_double: break
            out.append(ch)
        return ''.join(out)

    try:
        with open(path, "r", encoding="utf-8") as f:
            for raw in f:
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line: continue
                k, v = line.split("=", 1)
                k = k.strip()
                v = _strip_inline_comment(v).strip()
                if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
                    v = v[1:-1]
                v = os.path.expandvars(v)
                if not override and k in os.environ: continue
                os.environ[k] = v
        print(f"🔧 loaded .env: {path} (override={override})", flush=True)
    except Exception as e:
        print(f"⚠️ QLORA_ENV_FILE load failed: {type(e).__name__}: {e}", flush=True)

def _load_env_from_json_text(text, override=False):
    try:
        data = json.loads(text)
        if not isinstance(data, dict): raise ValueError("top-level is not an object")
        for k, v in data.items():
            if v is None: continue
            val = os.path.expandvars(str(v))
            if not override and k in os.environ: continue
            os.environ[k] = val
        print(f"🔧 loaded JSON config (override={override})", flush=True)
    except Exception as e:
        print(f"⚠️ QLORA_CONFIG JSON load failed: {type(e).__name__}: {e}", flush=True)

_override = os.getenv("QLORA_ENV_OVERRIDE", "0") == "1"
_env_file = os.getenv("QLORA_ENV_FILE")
if _env_file and os.path.isfile(_env_file):
    _load_env_from_file(_env_file, _override)
_cfg_json_file = os.getenv("QLORA_CONFIG_JSON")
if _cfg_json_file and os.path.isfile(_cfg_json_file):
    with open(_cfg_json_file, "r", encoding="utf-8") as _f:
        _load_env_from_json_text(_f.read(), _override)
elif os.getenv("QLORA_CONFIG"):
    _load_env_from_json_text(os.getenv("QLORA_CONFIG"), _override)
# =================================================

# ===== Env knobs =====
SEED         = int(os.environ.get("SEED","42"))
BS           = int(os.environ.get("BS","1"))
EPOCHS       = int(os.environ.get("EPOCHS","1"))
GA           = int(os.environ.get("GRAD_ACCUM","1"))
CLIP_NORM    = float(os.environ.get("CLIP_NORM","1.0"))
WARMUP_RATIO = float(os.environ.get("WARMUP_RATIO","0.03"))
LOG_N        = max(1, int(os.environ.get("LOG_INTERVAL","50")))
MAX_LEN      = int(os.environ.get("MAX_LEN","8192"))
HF_TOKEN     = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
GRAD_CP      = os.getenv("GRAD_CP","0") == "1"
# 参照KL
LAMBDA_KL    = float(os.environ.get("LAMBDA_KL", "0.0"))
KL_T         = float(os.environ.get("KL_T", "1.0"))
# データセット設定(新規)
SFT_LIMIT_ENV = os.environ.get("SFT_LIMIT", None)  # 既存の総上限(任意)
SFT_DATASETS  = os.environ.get("SFT_DATASETS")     # 例: "oNo-1/MedMCQA:ALL@train,oNo-1/OlympiadBench:8000"
SFT_DATASETS_JSON = os.environ.get("SFT_DATASETS_JSON")  # JSON: [{"name":"...", "max_n":null, "splits":["train"]}, ...]
SFT_SPLITS_DEF = os.environ.get("SFT_SPLITS", "train,validation,test")  # デフォルトで試すsplit

# full seeding
import random, numpy as np
random.seed(SEED); np.random.seed(SEED)
torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)

def _mlp_forward_identity(self, hidden_states, *args, **kwargs):
    return hidden_states

# ===== Dataset config parsing (new) =====
def _parse_default_splits():
    return [s.strip() for s in SFT_SPLITS_DEF.split(",") if s.strip()]

def _parse_datasets_plan():
    # 1) JSON優先
    if SFT_DATASETS_JSON:
        plan = []
        try:
            arr = json.loads(SFT_DATASETS_JSON)
            for x in arr:
                if isinstance(x, dict):
                    name = x.get("name") or x.get("repo") or x.get("id")
                    if not name: continue
                    max_n = x.get("max_n", None)
                    splits = x.get("splits", None)
                    if splits is not None and isinstance(splits, str):
                        splits = [s.strip() for s in splits.split(",") if s.strip()]
                    plan.append((name, max_n, splits))
                elif isinstance(x, (list, tuple)) and len(x)>=1:
                    name = x[0]; max_n = x[1] if len(x)>1 else None
                    splits = x[2] if len(x)>2 else None
                    plan.append((name, max_n, splits))
        except Exception as e:
            print(f"⚠️ SFT_DATASETS_JSON parse failed: {type(e).__name__}: {e}", flush=True)
        if plan: return plan
    # 2) 文字列 "repo[:limit][@split+split],repo2[:limit],..."
    if SFT_DATASETS:
        plan = []
        for part in [p.strip() for p in SFT_DATASETS.split(",") if p.strip()]:
            name = part; max_n=None; splits=None
            if "@" in part:
                base, sp = part.split("@",1)
                name = base
                splits = [s.strip() for s in sp.replace("+",",").split(",") if s.strip()]
            if ":" in name:
                nm, lim = name.split(":",1)
                name = nm.strip()
                lim = lim.strip().lower()
                if lim and lim not in ("all","none"):
                    try: max_n=int(lim)
                    except: max_n=None
                else:
                    max_n=None
            plan.append((name, max_n, splits))
        if plan: return plan
    # 3) デフォルト(従来通り)
    return [("oNo-1/MedMCQA", None, None), ("oNo-1/OlympiadBench", 8000, None)]

model_path=os.environ["MODEL_LOCAL_DIR"]; nvme_dir=os.environ["NVME_DIR"]
assert model_path and nvme_dir

cfg=AutoConfig.from_pretrained(model_path, trust_remote_code=True)
num_layers=cfg.num_hidden_layers
gpus=list(range(torch.cuda.device_count()))
device_map={"model.embed_tokens":"cuda:0"}
for i in range(num_layers): device_map[f"model.layers.{i}"]=f"cuda:{i%len(gpus)}"
device_map["model.norm"]=f"cuda:{gpus[-1]}"; device_map["lm_head"]=f"cuda:{gpus[-1]}"

# QLoRA: 4-bit NF4 + k-bit準備
bnb=BitsAndBytesConfig(load_in_4bit=True,bnb_4bit_use_double_quant=True,
                       bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16)

max_memory={f"cuda:{i}":"78GiB" for i in gpus}; max_memory["cpu"]="300GiB"
offload=os.path.join(nvme_dir,"offload"); os.makedirs(offload,exist_ok=True)

tok=AutoTokenizer.from_pretrained(model_path, trust_remote_code=True, use_fast=True)
if tok.pad_token is None and tok.eos_token is not None: tok.pad_token=tok.eos_token
tok.padding_side="right"; tok.truncation_side="right"

model=AutoModelForCausalLM.from_pretrained(
    model_path, trust_remote_code=True, quantization_config=bnb,
    device_map=device_map, low_cpu_mem_usage=True, offload_state_dict=True,
    offload_folder=offload, max_memory=max_memory, torch_dtype=torch.bfloat16,
)
model.config.use_cache=False
model=prepare_model_for_kbit_training(model)
if GRAD_CP:
    model.gradient_checkpointing_enable()

# MoE gate: eval固定+凍結
for name, module in model.named_modules():
    if name.endswith(".mlp.gate"):
        module.eval()
        for p in module.parameters(): p.requires_grad=False

# 最後層 self_attn の線形候補を自動検出(Q優先)
last_idx=num_layers-1; cand=set()
for n,m in model.named_modules():
    if f"model.layers.{last_idx}.self_attn." in n and "linear" in m.__class__.__name__.lower():
        cand.add(n.split(".")[-1])
prefs=[["q_a_proj","q_b_proj"],["kv_b_proj"],["q_proj","v_proj"],["wq","wv"],["qkv_proj"],["query_key_value"],["o_proj","wo"]]
targets=[]
for g in prefs:
    hit=[x for x in g if x in cand]
    if hit: targets=hit; break
if not targets: targets=sorted(cand)[:1] or ["o_proj"]
print("candidate attn linear:", sorted(cand))
print("use target_modules:", targets)

# LoRA(QLoRAのAdapter部)。最後層以外のLoRAは凍結
lora_cfg=LoraConfig(r=4, lora_alpha=8, lora_dropout=0.0,
                    target_modules=targets, bias="none", task_type="CAUSAL_LM")
model=get_peft_model(model,lora_cfg)
for n,m in model.named_modules():
    if "lora_" in n and (f"model.layers.{last_idx}." not in n):
        for p in getattr(m,"parameters",lambda:[])(): p.requires_grad=False

# ===================== データ部分(SFTテンプレ適用) =====================
from datasets import load_dataset
import string

def build_user_text(ex):
    q = str(ex.get("question","")).strip()
    opts = ex.get("choices") or ex.get("options") or None
    if isinstance(opts, list) and len(opts) > 0:
        abc = list(string.ascii_uppercase)
        lines = [f"{abc[i]}. {str(o)}" for i,o in enumerate(opts)]
        return f"{q}\n\n選択肢:\n" + "\n".join(lines)
    return q

def build_assistant_text(ex):
    ans = str(ex.get("answer","")).strip()
    cot = ex.get("cot") or ex.get("rationale") or ex.get("explanation")
    if cot:
        cot = str(cot).strip()
        return f"<think>\n{cot}\n</think>\n<answer>\n{ans}\n</answer>"
    else:
        return f"<answer>\n{ans}\n</answer>"

def to_messages(ex):
    return [
        {"role":"system","content":"You are DeepSeek-R1."},
        {"role":"user","content": build_user_text(ex)},
        {"role":"assistant","content": build_assistant_text(ex)},
    ]

DEFAULT_SPLITS = _parse_default_splits()
RAW_PLAN = _parse_datasets_plan()  # list of (name, max_n, splits|None)

# Arrow の型キャストエラー回避:ストリーミング(HF token明示)
def _stream_examples(repo_id, splits=None):
    splits = splits or DEFAULT_SPLITS
    for split_name in splits:
        try:
            ds = load_dataset(repo_id, split=split_name, streaming=True,
                              token=HF_TOKEN, trust_remote_code=True)
            for ex in ds:
                yield ex
        except Exception as e:
            print(f"⚠️ stream skip {repo_id}:{split_name} -> {type(e).__name__}: {e}", flush=True)

texts = []; name_fragments = []; resolved_datasets = []
limit = int(SFT_LIMIT_ENV) if SFT_LIMIT_ENV is not None else None

for name, max_n, splits in RAW_PLAN:
    added = 0
    for ex in _stream_examples(name, splits):
        if limit is not None and len(texts) >= limit: break
        if max_n is not None and added >= int(max_n): break
        messages = to_messages(ex)
        text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
        texts.append(text); added += 1
    frag = f"{name}[n{added}" + (f"/{max_n}]" if max_n is not None else "]")
    name_fragments.append(frag)
    resolved_datasets.append({
        "name": name,
        "max_n": max_n,
        "splits": splits or DEFAULT_SPLITS,
        "loaded": added
    })

SFT_DATASET_NAME = " + ".join(name_fragments)
SFT_SAMPLE_COUNT = len(texts)
if SFT_SAMPLE_COUNT == 0:
    raise RuntimeError("No samples loaded. Check HF token/permissions or dataset availability.")

# アシスタントのみ損失のカット点(トークン位置)
assistant_prefix_token_lens = []
for t in texts:
    cut_char = t.find("<think>")
    if cut_char == -1: cut_char = t.find("<answer>")
    if cut_char == -1: cut_char = 0
    enc_one = tok(t, truncation=True, max_length=MAX_LEN, padding=False,
                  add_special_tokens=True, return_offsets_mapping=True)
    offsets = enc_one["offset_mapping"]; cut_tok = len(offsets)
    for j, off in enumerate(offsets):
        if off is None: continue
        start = off[0] if isinstance(off, (list, tuple)) else None
        if start is not None and start >= cut_char:
            cut_tok = j; break
    assistant_prefix_token_lens.append(cut_tok)

# 動的パディングでエンコード
enc = tok(texts, truncation=True, max_length=MAX_LEN, padding=False)
collator = DataCollatorForLanguageModeling(tokenizer=tok, mlm=False)

# 初回シャッフル(再現性は SEED で制御)
order = list(range(len(enc["input_ids"]))); random.shuffle(order)
for k in list(enc.keys()): enc[k] = [enc[k][i] for i in order]
assistant_prefix_token_lens = [assistant_prefix_token_lens[i] for i in order]
# ===================== データ部分ここまで =====================

# optimizer(LoRAパラメータのみ)+ scheduler
trainable=[p for p in model.parameters() if p.requires_grad]
opt=PagedAdamW8bit(trainable, lr=5e-5)

model.train()

# Re-freeze MoE gate after model.train()
for name, module in model.named_modules():
    if name.endswith(".mlp.gate"):
        module.eval()
        for p in module.parameters(): p.requires_grad = False

# MLPはforward恒等にしない(品質維持)。パラメータは凍結のみ
for name, module in model.named_modules():
    if name.endswith(".mlp"):
        for p in module.parameters(): p.requires_grad = False

# DeepSeek MoE shared_experts の一時回避: 例外時のみ恒等フォールバック
from types import MethodType
def _wrap_mlp_forward_with_fallback(module):
    orig_forward = module.forward
    def _safe_forward(self, hidden_states, *args, **kwargs):
        try:
            return orig_forward(hidden_states, *args, **kwargs)
        except UnboundLocalError as e:
            if "y" in str(e):  # 'y referenced before assignment'
                return hidden_states
            raise
    module.forward = MethodType(_safe_forward, module)
for name, m in model.named_modules():
    if name.endswith(".mlp"): _wrap_mlp_forward_with_fallback(m)

first_device=model.get_input_embeddings().weight.device

# Training bookkeeping
num_samples = len(enc["input_ids"])
num_batches_per_epoch = math.ceil(num_samples / BS)
total_update_steps = math.ceil((num_batches_per_epoch * EPOCHS) / max(GA,1))
warmup_steps = int(WARMUP_RATIO * total_update_steps)
sched = get_linear_schedule_with_warmup(opt, warmup_steps, total_update_steps)

step = 0  # optimizer steps
accum = 0
opt.zero_grad(set_to_none=True)

def _epoch_reshuffle():
    global enc, assistant_prefix_token_lens
    order2 = list(range(len(enc["input_ids"]))); random.shuffle(order2)
    for k in list(enc.keys()): enc[k] = [enc[k][i] for i in order2]
    assistant_prefix_token_lens = [assistant_prefix_token_lens[i] for i in order2]

# Train loop(エポック駆動/勾配累積/動的パディング)
for epoch in range(EPOCHS):
    if epoch > 0: _epoch_reshuffle()

    for i in range(0, num_samples, BS):
        batch_inputs = {
            "input_ids": enc["input_ids"][i:i+BS],
            "attention_mask": enc["attention_mask"][i:i+BS],
        }
        batch = collator([
            {"input_ids": x, "attention_mask": y}
            for x, y in zip(batch_inputs["input_ids"], batch_inputs["attention_mask"])
        ])
        for k in list(batch.keys()):
            batch[k] = batch[k].to(first_device, non_blocking=True)

        # アシスタントのみ損失(<think>/<answer> 以前をマスク)
        labels = batch["labels"]; seq_len = labels.size(1)
        for b_idx, cut in enumerate(assistant_prefix_token_lens[i:i+BS]):
            c = min(cut, seq_len)
            if c > 0: labels[b_idx, :c] = -100
        batch["labels"] = labels

        out=model(**batch); loss=out.loss

        # 参照KL(LoRA無効=ベース)との距離を加算(LAMBDA_KL>0のときのみ)
        if LAMBDA_KL > 0.0:
            with torch.no_grad():
                try:
                    ctx = model.disable_adapter()
                except AttributeError:
                    ctx = torch.no_grad()
                with ctx:
                    ref_out = model(input_ids=batch["input_ids"],
                                    attention_mask=batch["attention_mask"])
                    ref_logits = ref_out.logits

            T = KL_T if KL_T > 0 else 1.0
            s = out.logits.float() / T
            t = ref_logits.float() / T
            logps = F.log_softmax(s, dim=-1)
            probs_t = F.softmax(t, dim=-1)
            kl_per_tok = F.kl_div(logps, probs_t, reduction='none').sum(-1)  # (B, S)
            mask = batch["attention_mask"]
            kl_mean = (kl_per_tok * mask).sum() / mask.sum().clamp(min=1)
            loss = loss + (LAMBDA_KL * (T * T)) * kl_mean

        loss.backward(); accum += 1

        should_step = (accum % max(GA,1) == 0)
        last_batch = (i + BS >= num_samples) and (epoch == EPOCHS-1)
        if should_step or last_batch:
            if CLIP_NORM and CLIP_NORM > 0:
                torch.nn.utils.clip_grad_norm_(trainable, CLIP_NORM)
            opt.step(); sched.step(); opt.zero_grad(set_to_none=True)
            accum = 0; step += 1
            if (step % LOG_N == 0) or last_batch:
                print(f"step {step}/{total_update_steps} loss={loss.item():.4f}", flush=True)

# 出力
now = datetime.now(JST) if JST else datetime.now()
ts_disp = now.strftime("%Y-%m-%d %H:%M:%S %Z") if JST else now.strftime("%Y-%m-%d %H:%M:%S")
ts_compact = now.strftime("%Y%m%d-%H%M%S")

dataset_slug = re.sub(r'[^A-Za-z0-9._-]+','-', SFT_DATASET_NAME.replace('/','-')).strip('-_') or "dataset"
outdir_name = f"qlora_{dataset_slug}__n{SFT_SAMPLE_COUNT}__{ts_compact}"
outdir = os.path.join(nvme_dir, outdir_name); os.makedirs(outdir, exist_ok=True)

# ====== config snapshot を保存(HF_TOKENはマスク) ======
def _snapshot_env():
    keys = [
        # core
        "MODEL_LOCAL_DIR","NVME_DIR","SEED","BS","EPOCHS","GRAD_ACCUM","MAX_LEN",
        "CLIP_NORM","WARMUP_RATIO","LOG_INTERVAL","GRAD_CP",
        # KD
        "LAMBDA_KL","KL_T",
        # dataset
        "SFT_LIMIT","SFT_DATASETS","SFT_DATASETS_JSON","SFT_SPLITS",
        # loader behavior
        "QLORA_ENV_FILE","QLORA_CONFIG_JSON","QLORA_ENV_OVERRIDE",
    ]
    snap = {}
    for k in keys:
        if k in os.environ: snap[k] = os.environ[k]
    # mask secrets if present
    if "HF_TOKEN" in os.environ: snap["HF_TOKEN"] = "***"
    if "HUGGING_FACE_HUB_TOKEN" in os.environ: snap["HUGGING_FACE_HUB_TOKEN"] = "***"
    return snap

config_snapshot = {
    "env": _snapshot_env(),
    "resolved_datasets": resolved_datasets,
    "dataset_name_fragments": name_fragments,
    "samples": SFT_SAMPLE_COUNT,
    "timestamp": ts_disp,
}
with open(os.path.join(outdir, "config_snapshot.json"), "w", encoding="utf-8") as f:
    json.dump(config_snapshot, f, ensure_ascii=False, indent=2)

# モデルと tokenizer の保存(従来)
model.save_pretrained(outdir); tok.save_pretrained(outdir)

# ラン情報の保存(従来)
info = {
  "timestamp": ts_disp,
  "dataset": SFT_DATASET_NAME,
  "samples": SFT_SAMPLE_COUNT,
  "epochs": EPOCHS,
  "bs": BS,
  "grad_accum": GA,
  "max_len": MAX_LEN,
}
with open(os.path.join(outdir, "sft_run_info.json"), "w", encoding="utf-8") as f:
    json.dump(info, f, ensure_ascii=False, indent=2)

print("✅ QLoRA loop done ->", outdir)
print(f"🕒 {ts_disp} | 📚 {SFT_DATASET_NAME} | 🔢 samples={SFT_SAMPLE_COUNT} | "
      f"🧮 steps={step}/{total_update_steps}")
PY

2.6. 実行

export NCCL_IB_DISABLE=1
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
export CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7

QLORA_ENV_OVERRIDE=1 QLORA_ENV_FILE=./qlora.env \
python train_qlora.py 2>&1 | tee -a train_loop.log

まとめ

本記事では、予選モデルの学習に用いたスクリプト一式(環境構築、NVMe キャッシュ設定、Slurm 実行例、qlora.envtrain_qlora.py、実行コマンド)を掲載しました。
実装のキーポイントは LAMBDA_KL=0.9 による参照 KL 正則化(忘却抑制)。この設定が HARI の中核です。

以上を踏まえ、まずは記事内の手順どおり環境を再現し、qlora.env の各値(特にデータセットとトークン)を自身の環境に合わせて調整して実行してください。

本プロジェクトは、国立研究開発法人新エネルギー・産業技術総合開発機構(以下「NEDO」)の「日本語版医療特化型LLMの社会実装に向けた安全性検証・実証」における基盤モデルの開発プロジェクトの一環として行われます。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?