🌟 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
Inference Providers
NEW
This model isn't deployed by any Inference Provider.
🙋
Ask for provider support
HF Inference deployability: The model has no library tag.