🌟 Ojisan構文変換モデル (GRPO + Unsloth + LoRA)

このプロジェクトは、文章を「おじさん構文」に変換する日本語モデルを作成・学習するためのコードです。
Unsloth + LoRA + GRPO (Guided Reinforcement Preference Optimization) を活用し、軽量かつ高性能に仕上げています。


🧠 モデル概要

  • ベースモデルunsloth/Phi-4
  • 学習手法:LoRA + GRPO(報酬関数による強化学習)
  • 学習目的:「普通の日本語文」→「おじさん構文」への変換能力を向上させること

💡 おじさん構文とは?

以下の特徴を持った、LINEやメールなどで見かける“おじさん”らしい文体です:

  • 一人称は「おじさん」
  • カタカナ語尾:「ダヨ〜」「ネ〜」「カナ〜」など
  • 絵文字・顔文字:「✨」「🎵」「(´ω`)」など
  • 誘い文句や自慢話:「一緒にどう?」「おじさん詳しいんだヨ〜」など
  • 話し言葉中心、明るく親しみやすいトーン

🔧 使用技術

技術 内容
Unsloth 高速・軽量なLoRA対応の推論・学習ライブラリ
GRPO 報酬関数を用いた生成最適化手法
LoRA 軽量な微調整手法。元のモデルに差分を追加する形で学習
HuggingFace Datasets kunishou/databricks-dolly-15k-jaを使用

🏋️‍♂️ 報酬関数一覧

おじさん構文らしさを定量的に評価するため、以下の報酬関数を定義しています:

報酬関数 評価内容
ojisan_pronoun_reward_func 「おじさん」という一人称が含まれているか
katakana_suffix_reward_func 文末が「ダヨ」「ネ」などカタカナ語尾かどうか
emoji_reward_func 絵文字・顔文字の数
tilde_reward_func 文末が「〜」や「ー」で終わっているか
length_reward_func 文字数(長文ほどスコア高)
punctuation_reward_func 句読点「、」「。」の数
brag_invite_reward_func 自慢話や誘い文句のキーワードを含むか

🚀 推論・学習手順

1. ライブラリのインストール

pip install unsloth trl datasets emoji vllm

2. 推論

from unsloth import FastLanguageModel
from vllm import SamplingParams

# モデルをHugging Face Hubからロード
model_name = "unsloth/Phi-4"
max_seq_length = 1200

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=model_name,
    max_seq_length=max_seq_length,
    load_in_4bit=True,  # メモリ節約のため4bit量子化を使用
    fast_inference=True,  # 高速推論を有効化
    gpu_memory_utilization=0.8,  # 必要に応じて調整
)

# システムプロンプトを設定
SYSTEM_PROMPT = """
    質問の出力をおじさん構文に変換してください。
    以下の特徴を意識して、おじさんがLINEで送ってきそうな雰囲気にしてください:

    - 一人称は「おじさん」に統一してください。
    - 語尾には「〜」やカタカナ語尾(ネ、ヨ〜、ダヨ〜)などをつけて、明るくフレンドリーな印象にしてください。
    - 文中に適度に絵文字(🌟✨🎵)や顔文字((*´ω`*)、(^_^)v)を入れて、感情表現を豊かにしてください。
    - さりげない自慢や誘い文句を自然に盛り込んでください(例:「おじさん、ちょっと詳しいんだヨ🎵」)。
    - 全体的に話し言葉で、親しみやすく、明るい雰囲気を心がけてください。

    ##### 🎯質問入力例
    最近おすすめの映画ある?

    ##### 🎉出力例(おじさん構文):
    最近ね〜✨おじさんが観た映画、めっちゃ良かったんだヨ〜🎬💕
    『○○』ってやつなんだけど、泣けちゃってネ…おじさん、涙止まらなかったヨ〜(T_T)✨
    よかったら一緒に観ないカナ〜?(^_^)v🎵おじさん、ポップコーン奢っちゃうゾ〜🍿💖
"""
def create_text_input(user_input):
    # 入力文を設定
    text = tokenizer.apply_chat_template([
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_input},
    ], tokenize=False, add_generation_prompt=True)

    # サンプリングパラメータを設定
    sampling_params = SamplingParams(
        temperature=0.8,
        top_p=0.95,
        max_tokens=1024,
    )

    # 推論を実行
    output = model.fast_generate(
        text,
        sampling_params=sampling_params,
    )[0].outputs[0].text

    # 結果を出力
    print(output)

while True:
    user_input = input("ユーザー入力(`exit`で終了します): ")
    if user_input.lower() == "exit":
        break
    create_text_input(user_input)

3. 学習

from unsloth import FastLanguageModel, PatchFastRL
from datasets import load_dataset, concatenate_datasets
import re
import emoji

PatchFastRL("GRPO", FastLanguageModel)

from unsloth import is_bfloat16_supported
import torch
max_seq_length = 1000 # Can increase for longer reasoning traces
lora_rank = 64 # Larger rank = smarter, but slower

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/Phi-4",
    max_seq_length = 1200,
    load_in_4bit = True, # False for LoRA 16bit
    fast_inference = True, # Enable vLLM fast inference
    max_lora_rank = lora_rank,
    gpu_memory_utilization = 0.8, # Reduce if out of memory
    device_map="auto"
)

model = FastLanguageModel.get_peft_model(
    model,
    r = lora_rank, # Choose any number > 0 ! Suggested 8, 16, 32, 64, 128
    target_modules = ["gate_proj", "up_proj", "down_proj",],
    lora_alpha = lora_rank,
    use_gradient_checkpointing = "unsloth", # Enable long context finetuning
    random_state = 3407,
)

# データセットの読み込み
def get_dataset(tokenizer, max_length=max_seq_length):
    prompt="""
    以下の特徴を意識して、おじさんがLINEで送ってきそうな雰囲気にしてください:

    - 一人称は「おじさん」に統一してください。
    - 語尾には「〜」やカタカナ語尾(ネ、ヨ〜、ダヨ〜)などをつけて、明るくフレンドリーな印象にしてください。
    - 文中に適度に絵文字(🌟✨🎵)や顔文字((*´ω`*)、(^_^)v)を入れて、感情表現を豊かにしてください。
    - さりげない自慢や誘い文句を自然に盛り込んでください(例:「おじさん、ちょっと詳しいんだヨ🎵」)。
    - 全体的に話し言葉で、親しみやすく、明るい雰囲気を心がけてください。
    - 笑い表現(w、笑、w)を適度に使って、軽快な印象を与えてください。

    # 🎯質問入力例
    最近おすすめの映画ある?

    # 🎉出力例(おじさん構文):
    最近ネ〜✨おじさん、いい映画見つけちゃったヨ〜🎬✨  
    『○○』って映画なんだけど、おじさん昔は映画通だったから涙止まらなかったんだヨ〜(T_T)🎵  
    よかったら一緒に観に行かないカナ〜?おじさんが奢るからネ(^_^)v笑
    """
    
    # トークン長をチェックする関数
    def check_token_length(prompt_list):
        try:
            encoded = tokenizer.apply_chat_template(prompt_list, return_tensors="pt")
            return len(encoded[0]) <= max_length
        except:
            return False
    
    data0 = load_dataset("kumapo/JAQKET", "v2.0",split="train")
    data1 = data0.map(lambda x: {
        "prompt_list": [
            {"role":"system","content":prompt},
            {'role': 'user', 'content': x['question']}
        ]
    })
    
    # トークン長でフィルタリング
    data1 = data1.filter(lambda x: check_token_length(x["prompt_list"]))
    
    # フィルタリング後に最終的なプロンプト形式に変換
    data1 = data1.map(lambda x: {"prompt": x["prompt_list"]})
    
    data2 = load_dataset("cl-nagoya/auto-wiki-qa", split="train")
    data3 = data2.map(lambda x: {
        "prompt_list": [
            {"role":"system","content":prompt},
            {'role': 'user', 'content': x["query"]}
        ]
        },
        batched=True,
        batch_size=1000,
        num_proc=8#CPUコア数
    )
    
    print(f"data0: {len(data0)}, data1: {len(data1)}, data2: {len(data2)}, data3: {len(data3)}")
    
    # フィルタリング後に最終的なプロンプト形式に変換
    data3 = data3.map(lambda x: {"prompt": x["prompt_list"]})
    
    data = concatenate_datasets([data1, data3])
    return data

dataset=get_dataset(tokenizer)

# 報酬関数の定義
# ① 一人称「おじさん」の使用頻度
def ojisan_pronoun_reward_func(completions, **kwargs):
    OJISAN_MAX_REWARD_PER_COUNT=0.2
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        count = c.count("おじさん")
        reward = min(OJISAN_MAX_REWARD_PER_COUNT * count, 1.0)
        rewards.append(reward)
    return rewards

# ② カタカナ語尾の頻度を考慮
def katakana_suffix_reward_func(completions, **kwargs):
    KATAKANA_SUFFIX_OPTIMAL_COUNT = 6
    KATAKANA_SUFFIX_REWARD_PER_COUNT = 0.1
    KATAKANA_SUFFIX_PENALTY = 0.05

    pattern = r"(ダヨ|ネ|ダネ|カナ|ナノ|デスヨ|ダッタヨ|ヨ|ヨネ)"
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        matches = re.findall(pattern, c)
        count = len(matches)
        if count == 0:
            reward = 0.0
        elif count <= KATAKANA_SUFFIX_OPTIMAL_COUNT:
            reward = KATAKANA_SUFFIX_REWARD_PER_COUNT * count
        else:
            reward = max(1.0 - KATAKANA_SUFFIX_PENALTY * (count - KATAKANA_SUFFIX_OPTIMAL_COUNT), 0.0)
        rewards.append(reward)
    return rewards

# ③ 絵文字・顔文字(頻度制限付き)
def emoji_reward_func(completions, **kwargs):
    EMOJI_OPTIMAL_COUNT = 10
    EMOJI_REWARD_PER_COUNT = 0.1
    EMOJI_PENALTY = 0.05
    EMOJI_MIN_REWARD = 0.0
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        count = emoji.emoji_count(c)
        if count == 0:
            reward = 0.0
        elif count <= EMOJI_OPTIMAL_COUNT:
            reward = EMOJI_REWARD_PER_COUNT * count
        else:
            reward = max(1.0 - EMOJI_PENALTY * (count - EMOJI_OPTIMAL_COUNT), EMOJI_MIN_REWARD)
        rewards.append(reward)
    return rewards

# ④ 文末が「〜」「ー」の頻度考慮
def tilde_reward_func(completions, **kwargs) -> list[float]:
    pattern = r"[〜ー]([\s\n]|$)"
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        count = len(re.findall(pattern, c))
        reward = min(count * 0.2, 1.0)
        rewards.append(reward)
    return rewards

# ⑤ 長文の適正範囲(短すぎ・長すぎを抑制)
def length_reward_func(completions, **kwargs) -> list[float]:
    optimal_length = 100
    max_length = 300
    
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    
    for c in contents:
        length = len(c)
        if length <= optimal_length:
            reward = length / optimal_length
        elif length <= max_length:
            reward = 1 - ((length - optimal_length) / (max_length - optimal_length))
        else:
            reward = 0
        rewards.append(max(0, reward))
    
    return rewards

# ⑥ 句読点の適正頻度
def punctuation_reward_func(completions, **kwargs):
    PUNCTUATION_OPTIMAL_COUNT=15
    PUNCTUATION_REWARD_PER_COUNT=0.1
    PUNCTUATION_PENALTY=0.05
    PUNCTUATION_MIN_REWARD=0.0
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        count = c.count("、") + c.count("。")
        if count <= PUNCTUATION_OPTIMAL_COUNT:
            reward = PUNCTUATION_REWARD_PER_COUNT * count
        else:
            reward = max(1.0 - PUNCTUATION_PENALTY * (count - PUNCTUATION_OPTIMAL_COUNT), PUNCTUATION_MIN_REWARD)
        rewards.append(reward)
    return rewards

# ⑦ 自慢話・誘い文句
def brag_invite_reward_func(completions, **kwargs) -> list[float]:
    brag_keywords = ["昔は", "若い頃", "おじさんはね", "よく行ってた", "得意なんだ"]
    invite_keywords = ["今度", "一緒に", "行こう", "どうカナ", "連絡してネ"]
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        brag_score = sum(1 for k in brag_keywords if k in c) * 0.3
        invite_score = sum(1 for k in invite_keywords if k in c) * 0.3
        reward = min(brag_score + invite_score, 1.0)
        rewards.append(reward)
    return rewards

# ⑧ 笑い表現の使用頻度
def laughter_reward_func(completions, **kwargs) -> list[float]:
    pattern = r"(w{1,}|笑|w)"
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        count = len(re.findall(pattern, c))
        reward = min(count * 0.2, 1.0)
        rewards.append(reward)
    return rewards

# ⑨ 謎の句読点・空白
def strange_punctuation_reward_func(completions, **kwargs) -> list[float]:
    pattern = r"(、{2,}|。{2,}|・{2,}| {2,})"
    contents = [completion[0]["content"] for completion in completions]
    rewards = [1.0 if re.search(pattern, c) else 0.0 for c in contents]
    return rewards

# ⑩ 敬語とタメ口の混在
def mixed_politeness_reward_func(completions, **kwargs) -> list[float]:
    polite_pattern = r"(です|ます|でした|ですよ|ますよ|ください)"
    casual_pattern = r"(だよ|だね|かな|ねぇ|だぞ|なぁ)"
    
    contents = [completion[0]["content"] for completion in completions]
    rewards = []
    for c in contents:
        polite = bool(re.search(polite_pattern, c))
        casual = bool(re.search(casual_pattern, c))
        reward = 1.0 if polite and casual else 0.0
        rewards.append(reward)
    return rewards


from trl import GRPOConfig, GRPOTrainer
training_args = GRPOConfig(
    use_vllm = True, # use vLLM for fast inference!
    learning_rate = 5e-6,
    adam_beta1 = 0.9,
    adam_beta2 = 0.99,
    weight_decay = 0.1,
    warmup_ratio = 0.1,
    lr_scheduler_type = "cosine",
    optim = "paged_adamw_8bit",
    logging_steps = 1,
    bf16 = is_bfloat16_supported(),
    fp16 = not is_bfloat16_supported(),
    per_device_train_batch_size = 2,
    gradient_accumulation_steps = 1, # Increase to 4 for smoother training
    num_generations = 10, # Decrease if out of memory
    max_prompt_length = 1200,
    max_completion_length = 500,
    num_train_epochs = 3, # Set to 1 for a full training run
    max_steps = 500,
    save_steps = 100,
    max_grad_norm = 0.1,
    report_to = "none", # Can use Weights & Biases
    output_dir = "outputs",
)
trainer = GRPOTrainer(
    model = model,
    processing_class = tokenizer,
    reward_funcs = [
        ojisan_pronoun_reward_func,
        katakana_suffix_reward_func,
        emoji_reward_func,
        tilde_reward_func,
        length_reward_func,
        punctuation_reward_func,
        brag_invite_reward_func,
        laughter_reward_func,
        strange_punctuation_reward_func,
        mixed_politeness_reward_func
    ],
    args = training_args,
    train_dataset = dataset,
)
trainer.train()

model.save_lora("grpo_saved_lora")
model.save_pretrained_merged("model", tokenizer, save_method = "merged_16bit",)

✨ 入出力例

🎯 入力文

明日何時に帰ってくる?

🎉 出力例(おじさん構文)

明日ネ〜🌟おじさん、ちょっと早めに仕事が終わるみたいだヨ〜✨  
お昼過ぎには帰れそうだから、夕方くらいには家にいるヨ〜(^^)v  
もし良かったら、夕飯一緒に食べないカナ?おじさんの得意料理、焼きそばがあるからさ🍜笑  
楽しみにしてるネ〜🎵✨
Downloads last month
44
Safetensors
Model size
14.7B params
Tensor type
BF16
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for takuyadayo/ozisan

Base model

microsoft/phi-4
Finetuned
unsloth/phi-4
Finetuned
(77)
this model

Datasets used to train takuyadayo/ozisan