背景介绍
最近我一直在研究如何让LLM更好地理解和生成特定领域的内容。我选择了自己喜欢的小说《逍遥小散仙》作为实验数据,希望通过SFT(Supervised Fine-Tuning)让模型学习小说的内容、人物关系和写作风格,最终能够进行相关的分析和续写。这篇文章详细记录了我的数据集创建过程,以及最终的微调结果。
数据集创建过程
数据预处理
首先将小说用calibre从epub格式转成txt格式,为后续的处理做准备。
方法一:使用LLM提取问答数据
第一种方法是使用LLM从小说文本中提取问答对。这种方法的核心思想是将小说分成多个段落,然后让LLM为每个段落生成相关的问题和答案。
1. 设置LLM接口
首先,我创建了llm.py
文件,用于与LLM API进行交互:
from openai import OpenAI
from typing import List, Dict
import json
import re
BaseURL = 'https://api.aiproxy.io/v1'
APIKEY = 'sk-xxxx' # 已隐藏真实API密钥
Model = 'gpt-4o-mini-2024-07-18'
# 也可以使用DeepSeek API
# BaseURL='https://api.deepseek.com/v1'
# APIKEY='sk-xxxx'
# Model='deepseek-chat'
client = OpenAI(api_key=APIKEY, base_url=BaseURL)
def get_qa(raw_content, stream=False) -> str:
response = client.chat.completions.create(
model=Model,
messages=[
{'role': 'system', 'content': '你是一位小说分析师,负责从以下文本中提取信息并生成问答对。输出格式要求:\n```json\n[{"question": "*", "answer": "*"}]```'},
{'role': 'user', 'content': f'请仔细阅读文本,并根据文本内容生成问题和答案,问答对要包含文本所有有效信息, 问答对互相独立,看不到其它问题。确保问题涵盖角色、情节、情感和背景等多方面。答案应直接复制原文内容。直接用JSON格式回答。文本:\n[{raw_content}]\n请生成JSON格式(列表)问答对:\n'}
],
temperature=0.7,
top_p=1.0,
max_tokens=4096,
stream=stream
)
if stream:
for chunk in response:
if chunk.choices[0].delta.content is not None:
print(chunk.choices[0].delta.content, end='')
else:
r_content = response.choices[0].message.content
json_match = re.findall(r'```(?:json)?\n(.*?)\n```', r_content, re.DOTALL)
return json_match[0] if json_match else None
2. 加载和处理数据
接下来,创建main.py
文件来加载小说文本并分块处理:
from datasets import load_dataset
from llm import get_qa
import tqdm
import json
import time
from typing import List
# 加载整个TXT文件作为训练集
dataset = load_dataset("text", data_files={"train": "xiaoyao.txt"}, sample_by="paragraph")
# 输出数据集信息
print(dataset)
chunk_size = 10
output_file = 'results.jsonl'
def convert_to_json(s) -> List:
try:
return json.loads(s)
except Exception:
return False
total_chunks = (len(dataset['train']) + chunk_size - 1) // chunk_size
pbar = tqdm.tqdm(total=len(dataset['train']))
for idx in tqdm.tqdm(range(0, len(dataset['train']), chunk_size), total=total_chunks):
chunk = '\n'.join(dataset['train'][idx: idx+chunk_size]['text'])
# 获取 QA 结果
response = get_qa(chunk)
# 处理响应并保存
try:
j_list = convert_to_json(response)
if isinstance(j_list, list):
with open(output_file, 'a', encoding='utf-8') as f:
for item in j_list:
if not item:
continue
json.dump(item, f, ensure_ascii=False)
f.write('\n')
else:
# 如果是单个对象,直接写入
with open(output_file, 'a', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False)
f.write('\n')
except Exception as e:
print(f"Error processing response: {e}")
这段代码的工作流程是:
- 使用
datasets
库加载小说文本,按段落进行分割 - 每次取10个段落组成一个chunk
- 将chunk发送给LLM进行问答对生成
- 解析LLM返回的JSON格式问答对
- 将问答对保存到JSONL文件中
3. 数据转换为SFT格式
生成问答对后,需要将其转换为SFT训练所需的格式。创建convert.py
文件:
import json
# 初始化一个空列表来存储转换后的数据
data = []
with open('results.jsonl', 'r', encoding='utf-8') as file:
for line in file:
# 解析每一行的 JSON 数据
json_object = json.loads(line)
new_format = {
"instruction": json_object["question"],
"input": "", # 如果需要,可以根据需要填充
"output": json_object["answer"]
}
# 将新格式字典添加到列表中
data.append(new_format)
with open('output.json', 'w', encoding='utf-8') as outfile:
json.dump(data, outfile, ensure_ascii=False, indent=2)
这段代码将问答对转换为SFT训练常用的指令格式:
instruction
:问题input
:输入(在这个场景中为空)output
:答案
4. 生成的数据示例
以下是生成的问答对数据示例:
{
"instruction": "小玄对自己的处境有何自我安慰的想法?",
"input": "",
"output": "忽尔厚颜无耻地思道:男子汉大丈夫,自古以来就三妻四妾,她们老爹不就娶了五房夫人嘛,岳父可以放火,女婿就不能点灯么,姐妹俩总不能一点情理也不讲吧......"
},
{
"instruction": "水若对小玄的反应是什么?",
"input": "",
"output": "水若突地一呆,用手抵住了他的胸口,怔怔盯了他片刻,猛然地将其推开,挣扎坐起,恼恨交加道:滚开!以后,再不许你碰我!"
}
方法二:提取对话数据
第二种方法是提取小说中的对话内容。我使用了extract-dialogue
工具来完成这项工作,该工具可以从小说文本中自动识别并提取对话内容,包括说话人和对话内容。
1. 安装和配置工具
首先,克隆工具仓库并安装依赖:
git clone https://github.com/KMnO4-zx/extract-dialogue.git
cd extract-dialogue && pip install -r requirements.txt
2. 配置API
创建.env
文件,配置DeepSeek API:
# 创建.env文件
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_API=sk-xxxx
3. 创建主程序
创建main.py
文件,用于处理小说文本并提取对话:
from extract import system_prompt
from schema import novel_schema
from LLM import DeepseekChat
from utils import ReadFiles
from tqdm import tqdm
import json
# 指定小说文件路径
file_path = './xiaoyao.txt'
# 读取文件内容并分块,每块最大token数为500
docs = ReadFiles(file_path).get_content(max_token_len=500, cover_content=0)
# 获取系统提示词,用于指导LLM提取对话
sys_prompt = system_prompt(novel_schema)
# 初始化DeepSeek模型
model = DeepseekChat()
# 获取文件名(不含扩展名)用于输出文件命名
file_name = file_path.split('/')[-1].split('.')[0]
# 遍历所有文本块,提取对话
for i in tqdm(range(len(docs))):
response = model.chat(sys_prompt, docs[i])
try:
# 解析JSON响应
response = json.loads(response)
# 将每条对话写入JSONL文件
for item in response:
with open(f'{file_name}.jsonl', 'a', encoding='utf-8') as f:
json.dump(item, f, ensure_ascii=False)
f.write('\n')
except Exception as e:
print(e)
4. 提取结果示例
运行程序后,会得到包含角色和对话内容的JSONL文件,每行是一个JSON对象:
{"role": "小玄", "dialogue": "办不到。"}
{"role": "方少麟", "dialogue": "办不到?你是不想?还是办不到?"}
{"role": "小玄", "dialogue": "换个条件。"}
{"role": "方少麟", "dialogue": "我想不出别的。如果这两个都做不到,那么我无法相信你之前说的话。"}
{"role": "方少麟", "dialogue": "行,你告诉我,除此之外,你还能做到什么?"}
{"role": "小玄", "dialogue": "我能让皇朝军退兵。如果你也退回泽阳,就可避免两败俱伤,令万千生灵涂炭!"}
{"role": "方少麟", "dialogue": "我不能。朝廷失政日久,如今天下荒荒,皆要推倒昏君,如果你无法证明昏君已经不在,凭我是说服不了别人的。"}
失败原因与经验总结
后续使用了LammaFactory工具进行SFT微调,测试效果都比较差,模型只能简单记忆问答对,而不能进行更复杂的对话。我认为一方面是数据集质量比较差,并且在微调时候产生了过拟合,模型遗忘了原有的能力。但更主要问题在于直接进行SFT可能不是最佳选择,应该先进行领域适应性的预训练。此外,数据集的规模和质量也有待提高。