minimind-o/dataset/omni_dataset.py

345 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import torch
import os
import math
import random
import numpy as np
import soundfile as sf
import librosa
import json
import io
from PIL import Image
from scipy.signal import resample
from torch.utils.data import Dataset
import pyarrow as pa
import pyarrow.parquet as pq
os.environ["TOKENIZERS_PARALLELISM"] = "false"
def pre_processing_chat(conversations, add_system_ratio=0.2):
if any(conv.get('tools') for conv in conversations): return conversations
SYSTEM_PROMPTS = [
"你是一个知识丰富的AI,尽力为用户提供准确的信息。",
"你是minimind,一个小巧但有用的语言模型。",
"你是一个专业的AI助手,请提供有价值的回答。",
"你是minimind,请尽力帮助用户解决问题。",
"你是一个可靠的AI,请给出准确的回答。",
"You are a helpful AI assistant.",
"You are minimind, a lightweight intelligent assistant.",
"You are a friendly chatbot. Please answer the user's questions carefully.",
"You are a knowledgeable AI. Try your best to provide accurate information.",
"You are minimind, a small but useful language model."
]
if conversations[0].get('role') != 'system':
if random.random() < add_system_ratio:
return [{'role': 'system', 'content': random.choice(SYSTEM_PROMPTS)}] + conversations
return conversations
def post_processing_chat(prompt_content, empty_think_ratio=0.2):
if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
return prompt_content
class OmniDataset(Dataset):
def __init__(self, data_path, tokenizer, audio_processor=None, vision_processor=None,
max_length=1200, audio_special_token='<|audio_pad|>', image_special_token='<|image_pad|>',
audio_stop_token=2050, # <|audio_stop|>
audio_pad_token=2049, # <|audio_pad|>
audio_spk_token=2051, # <|audio_spk|>
audio_vocab_size=2112, # 2048 mimi codes + 64 special tokens
scheduled_sampling=0.05,
image_token_len=64):
super().__init__()
tables = [pa.Table.from_batches(pq.ParquetFile(p.strip()).iter_batches()) for p in data_path.split(',')]
tables = [t.cast(pa.schema([f.with_type(pa.large_string()) if pa.types.is_string(f.type) else f for f in t.schema])) for t in tables]
self.table = pa.concat_tables(tables, promote_options='default')
self.tokenizer = tokenizer
self.audio_processor = audio_processor
self.vision_processor = vision_processor
self.max_length = max_length
self.audio_token = audio_special_token
self.image_token_len = image_token_len
self.image_token = image_special_token * image_token_len
self.audio_stop_token = audio_stop_token
self.audio_pad_token = audio_pad_token
self.audio_spk_token = audio_spk_token
self.audio_vocab_size = audio_vocab_size
self.scheduled_sampling_prob = scheduled_sampling
self.text_vocab_size = len(tokenizer)
self.image_token_id = tokenizer.encode(image_special_token, add_special_tokens=False)[0]
self.audio_token_id = tokenizer.encode(audio_special_token, add_special_tokens=False)[0]
self.think_end_ids = tokenizer.encode('</think>\n\n', add_special_tokens=False)
self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
def __len__(self):
return len(self.table)
@staticmethod
def process_audio(audio_path, audio_processor):
"""加载音频并预处理成fbank,返回 (fbank (T,560), valid_len=encoder输出帧数)"""
wav, sr = sf.read(audio_path)
if wav.ndim > 1: wav = wav.mean(axis=1)
if sr != 16000: wav = librosa.resample(wav.astype(float), orig_sr=sr, target_sr=16000)
inputs = audio_processor(wav.astype(np.float32), sampling_rate=16000, return_tensors="pt", return_attention_mask=True)
valid_len = inputs.attention_mask.sum().item()
return inputs.input_features.squeeze(0), valid_len
def augment_wav(self, wav, sr=16000):
# 随机变速(0.7~1.6x):改变音频时长和音调,覆盖快/慢语速
if random.random() < 0.5:
speed = random.uniform(0.7, 1.6)
wav = resample(wav, int(len(wav) / speed)).astype(np.float32)
# 随机加噪:叠加轻微高斯白噪声,模拟录音环境差异
if random.random() < 0.3:
noise = np.random.randn(len(wav)).astype(np.float32) * random.uniform(0.001, 0.01)
wav = wav + noise
# 随机音量:缩放振幅0.8~1.2倍,模拟说话音量变化
if random.random() < 0.3:
wav = wav * random.uniform(0.8, 1.2)
# 随机时间遮蔽:将0.25秒片段置零,模拟短暂静音/丢包
if random.random() < 0.2 and len(wav) > sr:
start = random.randint(0, len(wav) - sr // 4)
wav[start:start + sr // 4] = 0
# 随机低通滤波:移动平均模糊高频,模拟电话/低质量麦克风
if random.random() < 0.2:
k = random.choice([3, 5, 7])
wav = np.convolve(wav, np.ones(k) / k, mode='same').astype(np.float32)
# 随机混响:合成指数衰减脉冲响应卷积,模拟房间反射/回声
if random.random() < 0.3:
ir_len = int(sr * random.uniform(0.05, 0.2))
ir = np.random.randn(ir_len).astype(np.float32) * np.exp(-np.linspace(0, 10, ir_len))
ir[0] = 1.0
ir /= np.sqrt(np.sum(ir ** 2) + 1e-6)
wav = np.convolve(wav, ir, mode='same').astype(np.float32)
# 随机粉红噪声:1/f噪声模拟房间环境底噪(空调/远处人声)
if random.random() < 0.2:
pink = np.cumsum(np.random.randn(len(wav))).astype(np.float32)
pink /= np.max(np.abs(pink)) + 1e-6
wav = wav + pink * random.uniform(0.003, 0.015)
return np.clip(wav, -1.0, 1.0).astype(np.float32)
def augment_mel(self, fbank):
# fbank: (T, 560) — SenseVoice LFR 后的特征(时间维在前,频率维在后)
T, D = fbank.shape
# SpecAugment频率遮蔽:随机抹掉1~64维,防止对特定频段过拟合
if random.random() < 0.5:
f = random.randint(1, 64)
f0 = random.randint(0, D - f)
fbank[:, f0:f0 + f] = 0
# SpecAugment时间遮蔽:随机抹掉1~min(10,T)帧,提升对不完整输入的容错
if random.random() < 0.5 and T > 1:
t = random.randint(1, min(10, T))
t0 = random.randint(0, T - t)
fbank[t0:t0 + t, :] = 0
return fbank
def load_audio_inputs(self, audio_bytes):
if not audio_bytes: return None, 0
wav, sr = sf.read(io.BytesIO(audio_bytes))
if wav.ndim > 1: wav = wav.mean(axis=1)
if sr != 16000: wav = librosa.resample(wav.astype(float), orig_sr=sr, target_sr=16000)
wav = self.augment_wav(wav.astype(np.float32))
inputs = self.audio_processor(wav, sampling_rate=16000, return_tensors="pt", return_attention_mask=True)
valid_len = inputs.attention_mask.sum().item()
return self.augment_mel(inputs.input_features.squeeze(0)), valid_len
def load_image_inputs(self, image_bytes):
if not image_bytes or self.vision_processor is None: return None
image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
inputs = self.vision_processor(images=image, return_tensors="pt")
if hasattr(inputs, 'keys'): return {k: v for k, v in inputs.items()}
return inputs.pixel_values
def create_chat_prompt(self, conversations, audio_features_length=0):
conversations = pre_processing_chat(conversations)
messages = []
is_last_user = lambda i: i == max(j for j, t in enumerate(conversations) if t['role'] == 'user')
for idx, turn in enumerate(conversations):
role, content = turn['role'], turn['content']
if role == 'user' and is_last_user(idx) and audio_features_length > 0:
ap = self.audio_token * audio_features_length
r = random.random()
if r < 0.4: content = ap
elif r < 0.6: content = content
elif r < 0.8: content = ap + '\n\n' + content
else: content = content + '\n\n' + ap
if '<image>' in content:
r = random.random()
if r < 0.2: content = '<image>\n' + content.replace('<image>', '').strip()
elif r < 0.4: content = '<image>\n\n' + content.replace('<image>', '').strip()
elif r < 0.6: content = content.replace('<image>', '').strip() + '\n' + '<image>'
else: content = content.replace('<image>', '').strip() + '\n\n' + '<image>'
messages.append({"role": role, "content": content})
prompt = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return post_processing_chat(prompt)
def generate_text_labels(self, input_ids):
labels = [-100] * len(input_ids)
ranges = []
i = 0
while i < len(input_ids):
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break
end += 1
ranges.append((start, end))
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
else:
i += 1
return labels, ranges
def apply_scheduled_sampling(self, input_ids, audio_labels, text_labels):
"""Scheduled Sampling: 用随机值替代部分GT,让模型学会从错误历史中恢复"""
if self.scheduled_sampling_prob <= 0:
return input_ids
audio_mask = (audio_labels != -100).any(dim=0) & (torch.rand(input_ids.size(1)) < self.scheduled_sampling_prob)
for i in range(8):
input_ids[i] = torch.where(audio_mask, torch.randint(0, self.audio_vocab_size, input_ids[i].shape), input_ids[i])
# 保护 image token 的连续性
text_mask = (text_labels != -100) & (input_ids[8] != self.image_token_id) & (torch.rand(input_ids.size(1)) < self.scheduled_sampling_prob)
input_ids[8] = torch.where(text_mask, torch.randint(0, self.text_vocab_size, input_ids[8].shape), input_ids[8])
return input_ids
def __getitem__(self, index: int):
conversations = json.loads(self.table['conversations'][index].as_py())
question_audios = self.table['question_audios'][index].as_py() if 'question_audios' in self.table.column_names else []
answer_audios = self.table['answer_audios'][index].as_py() if 'answer_audios' in self.table.column_names else []
image_bytes = self.table['image_bytes'][index].as_py() if 'image_bytes' in self.table.column_names else []
if image_bytes and not isinstance(image_bytes, list): image_bytes = [image_bytes]
ref_audios = self.table['ref_audios'][index].as_py() if 'ref_audios' in self.table.column_names else []
spk_emb_raw = self.table['spk_emb'][index].as_py() if 'spk_emb' in self.table.column_names else []
# 随机截断到某一轮(每轮=user+assistant)
asst_indices = [i for i, t in enumerate(conversations) if t['role'] == 'assistant']
if len(asst_indices) > 1:
rand_idx = random.randint(0, len(asst_indices) - 1)
# 从随机轮次开始,向前回退直到长度安全
for i in range(rand_idx, -1, -1):
conversations = conversations[:asst_indices[i] + 1]
test_prompt = self.create_chat_prompt(conversations, 0)
if len(self.tokenizer(test_prompt).input_ids) + 100 < self.max_length:
break
# 加载最后一个user的图像(按user轮次索引访问,与audio一致)
pixel_values = None
user_count = sum(1 for t in conversations if t['role'] == 'user')
if image_bytes and len(image_bytes) > 0 and self.vision_processor:
pixel_values = self.load_image_inputs(image_bytes[0])
# 只加载最后一个user的audio(按user轮次索引访问)
audio_inputs, audio_len, audio_features_length = None, 0, 0
user_count = sum(1 for t in conversations if t['role'] == 'user')
if question_audios and user_count > 0 and user_count <= len(question_audios) and self.audio_processor:
audio_bytes = question_audios[user_count - 1]
if audio_bytes:
mel, valid_len = self.load_audio_inputs(audio_bytes)
if mel is not None:
audio_inputs = mel.unsqueeze(0)
audio_len = valid_len
audio_features_length = valid_len or 1
# 混合训练时,无音频样本返回dummy tensor保持batch索引尽可能对齐 (SenseVoice: T x 560)
if audio_inputs is None and self.audio_processor:
audio_inputs = torch.zeros(1, 1, 560)
audio_len = 0
if pixel_values is None and self.vision_processor:
pixel_values = {'pixel_values': torch.zeros(1, 3, 256, 256)}
# 从answer_audios获取最后一个assistant的音频codes
last_audio_codes = None
asst_count = sum(1 for t in conversations if t['role'] == 'assistant')
if answer_audios and asst_count > 0 and asst_count <= len(answer_audios):
tokens = answer_audios[asst_count - 1]
if tokens:
audio_codes_8layers = [[] for _ in range(8)]
for i in range(0, len(tokens) - 7, 8):
for j in range(8): audio_codes_8layers[j].append(tokens[i + j])
for layer in audio_codes_8layers: layer.append(self.audio_stop_token)
last_audio_codes = audio_codes_8layers
# 生成prompt (text input_ids)
prompt = self.create_chat_prompt(conversations, audio_features_length)
if pixel_values is not None: prompt = prompt.replace('<image>', self.image_token)
input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
# PAD input_ids到max_length
input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
# 生成labels(只训练最后一个assistant)
text_labels, assistant_ranges = self.generate_text_labels(input_ids)
for start, end in assistant_ranges[:-1]:
mask_end = min(end + len(self.eos_id), self.max_length)
text_labels[start:mask_end] = [-100] * (mask_end - start)
# 生成7层audio targets(只填充最后一个assistant)
Y_audio_layers = [[self.audio_pad_token] * self.max_length for _ in range(8)]
audio_labels = [[-100] * self.max_length for _ in range(8)]
if assistant_ranges and last_audio_codes:
assistant_start, assistant_end = assistant_ranges[-1]
for pos in range(assistant_start, min(assistant_end, assistant_start + 50)):
if input_ids[pos:pos + len(self.think_end_ids)] == self.think_end_ids:
assistant_start = pos + len(self.think_end_ids)
break
# spk_emb 占位 + ref_codes 右对齐(50% 概率 drop ref_codes,只保留 spk)
has_spk = bool(spk_emb_raw)
has_ref = bool(ref_audios) and random.random() > 0.5
spk_reserve = 1 if has_spk else 0
if has_ref:
ref_codes = [[] for _ in range(8)]
for i in range(0, len(ref_audios) - 7, 8):
for j in range(8): ref_codes[j].append(ref_audios[i + j])
ref_len = len(ref_codes[0])
ref_start = max(spk_reserve, assistant_start - ref_len)
for layer_idx in range(8):
codes = ref_codes[layer_idx][-(assistant_start - ref_start):] if ref_len > (assistant_start - ref_start) else ref_codes[layer_idx]
for i, code in enumerate(codes):
Y_audio_layers[layer_idx][ref_start + i] = code
else:
ref_start = assistant_start
if has_spk and ref_start > 0:
spk_pos = ref_start - 1
for layer_idx in range(8):
Y_audio_layers[layer_idx][spk_pos] = self.audio_spk_token
# target codes 填充到 assistant_start 之后(参与 loss)
for layer_idx in range(8):
codes = last_audio_codes[layer_idx]
start_pos = assistant_start + layer_idx + 1
for i, code in enumerate(codes):
if start_pos + i < self.max_length:
Y_audio_layers[layer_idx][start_pos + i] = code
audio_labels[layer_idx][start_pos + i] = code
# 构造9路输入:input_ids = (9, T) = 8路audio + 1路text
X_audio = torch.tensor([layer[:-1] for layer in Y_audio_layers], dtype=torch.long) # (8, T-1)
X_text = torch.tensor(input_ids[:-1], dtype=torch.long) # (T-1,)
input_ids = torch.cat((X_audio, X_text.unsqueeze(0)), dim=0) # (9, T-1)
text_labels = torch.tensor(text_labels[1:], dtype=torch.long) # (T-1,)
audio_labels = torch.tensor([layer[1:] for layer in audio_labels], dtype=torch.long) # (8, T-1)
input_ids = self.apply_scheduled_sampling(input_ids, audio_labels, text_labels)
spk_emb = torch.tensor(spk_emb_raw, dtype=torch.float32) if spk_emb_raw else torch.zeros(192)
return input_ids, text_labels, audio_labels, audio_inputs, audio_len, pixel_values, spk_emb
# 测试parquet数据读取
if __name__ == '__main__':
for path in ['sft_a2a.parquet']:
if not os.path.exists(path): continue
t = pa.Table.from_batches(pq.ParquetFile(path).iter_batches())
conversations = json.loads(t['conversations'][0].as_py())
answer_audios = t['answer_audios'][0].as_py() if 'answer_audios' in t.column_names else []
user_msg = conversations[0]
asst_msg = conversations[1] if len(conversations) > 1 else {}
print(f'{path}: {len(t)}条, 列{t.column_names}')
print(f' User: {user_msg["content"][:50]}...')
print(f' Asst: {asst_msg.get("content", "")[:50]}...')
if answer_audios:
print(f' answer_audios: {len(answer_audios)}轮, 首轮{len(answer_audios[0])}tokens')