TinyOmni-ES-0.8B
Un modelo multimodal unificado de 0.8B parámetros que procesa audio, imagen, video y texto en español a través de un solo transformer.
TinyOmni-ES combina Qwen3.5-0.8B (vision-language model con arquitectura híbrida DeltaNet+Attention) con un AudioProjector entrenado sobre Whisper-small, inyectando tokens de audio via masked_scatter — el mismo mecanismo que Qwen3.5 usa nativamente para imagen y video. Todas las modalidades fluyen por las mismas capas de atención, habilitando verdadera fusión cross-modal.
Arquitectura
Audio WAV → Whisper-small encoder → AudioProjector (768→1024) → <|audio_pad|> tokens ─┐
Imagen → ViT nativo de Qwen3.5 ─────────────────────────── → <|image_pad|> tokens ──┤
Video → ViT nativo de Qwen3.5 ─────────────────────────── → <|video_pad|> tokens ──┤
Texto → Embedding layer ────────────────────────────────── → text tokens ───────────┘
↓
Qwen3.5 language model (24 capas)
20 DeltaNet (linear attention) + 4 full attention
↓
Texto generado
Componentes:
- Backbone: Qwen3.5-0.8B (
Qwen3_5ForConditionalGeneration) — 853M params - Audio encoder: Whisper-small encoder (244M, frozen) + AudioProjector MLP (3.7M, trained)
- Vision encoder: ViT nativo de Qwen3.5 (100M, parte del backbone)
Benchmarks
| Modalidad | Métrica | TinyOmni-ES | Qwen3.5 Vanilla | Delta |
|---|---|---|---|---|
| Audio | WER (Common Voice ES) | 21.24% | N/A | — |
| Vision | Keyword Overlap (XM3600) | 0.279 | 0.071 | +295% |
| Vision | BERTScore F1 (XM3600) | 0.781 | 0.624 | +25% |
| Texto | Calidad factual | Correcta | Alucina | Corregido |
Entrenamiento:
- Phase 1: AudioProjector con Muon optimizer (100K mels, WER 19.18%)
- Phase 2: Joint SFT audio+vision+texto con LoRA (22K samples mixtos)
- Phase 3: GRPO con 5 reward functions (hallucination, keyword overlap, conciseness, español, formato)
Quickstart — Vision (Describir imágenes)
from transformers import Qwen3_5ForConditionalGeneration, AutoProcessor
from PIL import Image
import torch
model = Qwen3_5ForConditionalGeneration.from_pretrained(
"RamsesCamas/TinyOmni-ES-0.8B",
torch_dtype=torch.bfloat16,
device_map="auto",
)
processor = AutoProcessor.from_pretrained("RamsesCamas/TinyOmni-ES-0.8B")
image = Image.open("foto.jpg").convert("RGB")
messages = [{"role": "user", "content": [
{"type": "image", "image": image},
{"type": "text", "text": "Describe esta imagen en español."},
]}]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = processor(text=[text], images=[image], return_tensors="pt").to(model.device)
output = model.generate(**inputs, max_new_tokens=200, do_sample=False)
response = processor.decode(output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
print(response)
# → "Calle de construcción con tierra y piedra en el patio de un edificio"
Quickstart — Audio (Transcribir español)
El audio requiere cargar el AudioProjector por separado:
import torch
from transformers import Qwen3_5ForConditionalGeneration, AutoProcessor, WhisperModel, WhisperProcessor
from huggingface_hub import hf_hub_download
# Cargar modelo principal
model = Qwen3_5ForConditionalGeneration.from_pretrained(
"RamsesCamas/TinyOmni-ES-0.8B", torch_dtype=torch.bfloat16, device_map="auto")
processor = AutoProcessor.from_pretrained("RamsesCamas/TinyOmni-ES-0.8B")
tokenizer = processor.tokenizer
# Cargar Whisper encoder
whisper = WhisperModel.from_pretrained("openai/whisper-small", torch_dtype=torch.float16).encoder
whisper = whisper.to(model.device).eval()
whisper_proc = WhisperProcessor.from_pretrained("openai/whisper-small")
# Cargar AudioProjector
# Descargar audio_projector.py y audio_projector.pt del repo
proj_code = hf_hub_download("RamsesCamas/TinyOmni-ES-0.8B", "audio_projector.py")
proj_weights = hf_hub_download("RamsesCamas/TinyOmni-ES-0.8B", "audio_projector.pt")
import importlib.util
spec = importlib.util.spec_from_file_location("audio_projector", proj_code)
ap_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ap_module)
projector = ap_module.AudioProjector(whisper_dim=768, qwen_dim=1024, hidden_dim=2048, dropout=0.1)
state = torch.load(proj_weights, map_location="cpu", weights_only=True)
projector.load_state_dict(state, strict=False)
projector = projector.to(model.device).eval()
# Audio tokens
audio_pad_id = tokenizer.convert_tokens_to_ids("<|audio_pad|>")
audio_start_id = tokenizer.convert_tokens_to_ids("<|audio_start|>")
audio_end_id = tokenizer.convert_tokens_to_ids("<|audio_end|>")
# Cargar audio (16kHz mono)
import librosa
audio, sr = librosa.load("audio.mp3", sr=16000)
# Procesar
mel = whisper_proc(audio, sampling_rate=16000, return_tensors="pt").input_features.to(model.device)
with torch.no_grad():
features = whisper.forward(mel.half()).last_hidden_state
audio_embeds = projector(features.float())
# Construir input con audio tokens
num_tokens = audio_embeds.shape[1]
prompt = "Transcribe el siguiente audio en español:\n"
audio_ids = [audio_start_id] + [audio_pad_id] * num_tokens + [audio_end_id]
prompt_ids = tokenizer(prompt, add_special_tokens=False).input_ids
input_ids = torch.tensor([audio_ids + prompt_ids], device=model.device)
# Inyectar audio embeddings
embed_layer = model.model.get_input_embeddings()
inputs_embeds = embed_layer(input_ids)
audio_mask = (input_ids == audio_pad_id).unsqueeze(-1).expand_as(inputs_embeds)
audio_flat = audio_embeds.reshape(-1, audio_embeds.shape[-1]).to(inputs_embeds.dtype)
inputs_embeds = inputs_embeds.masked_scatter(audio_mask, audio_flat)
# Generar
with torch.no_grad():
output = model.generate(inputs_embeds=inputs_embeds, max_new_tokens=128, do_sample=False)
print(tokenizer.decode(output[0], skip_special_tokens=True))
# → "Posteriormente, Barrett fue reemplazado por Diego Suárez."
Quickstart — Texto
from transformers import Qwen3_5ForConditionalGeneration, AutoProcessor
import torch
model = Qwen3_5ForConditionalGeneration.from_pretrained(
"RamsesCamas/TinyOmni-ES-0.8B", torch_dtype=torch.bfloat16, device_map="auto")
processor = AutoProcessor.from_pretrained("RamsesCamas/TinyOmni-ES-0.8B")
messages = [{"role": "user", "content": [
{"type": "text", "text": "¿Cuál es la capital de Francia?"},
]}]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = processor.tokenizer(text, return_tensors="pt").to(model.device)
output = model.generate(**inputs, max_new_tokens=100, do_sample=False)
generated = output[0][inputs["input_ids"].shape[1]:]
print(processor.tokenizer.decode(generated, skip_special_tokens=True))
# → "La capital de Francia es la París."
Detalles de entrenamiento
Phase 1 — Audio Alignment
- Datos: 100K mel spectrograms de Common Voice ES v24 (Mozilla Data Collective)
- Método: AudioProjector MLP (768→2048→1024) entrenado con Muon optimizer
- Hardware: RTX 3090 24GB, ~48h total
- Resultado: WER 19.18% en Common Voice ES test
Phase 2 — Joint Audio-Visual SFT
- Datos: 22K samples mixtos (3.6K audio+vision, 3.6K vision, 10K audio, 5K texto)
- Método: LoRA r=16 en 7 target modules (q_proj, v_proj, in_proj_qkv, in_proj_z, gate_proj, up_proj, down_proj) sobre
Qwen3_5ForConditionalGeneration - Arquitectura: Audio tokens inyectados via
masked_scatteren<|audio_pad|>— mismo patrón que imagen/video nativos de Qwen3.5 - Resultado: WER 20.93%, visión funcional, texto correcto
Phase 3 — GRPO (Group Relative Policy Optimization)
- Datos: 3,535 imágenes XM3600 español con 5 reward functions
- Rewards: hallucination (Ollama 4B, 0.40), keyword overlap (0.20), conciseness (0.20), español (0.10), formato (0.10)
- Método: GRPO con β=0.04, G=2, sobre el LoRA de Phase 2
- Resultado: KW Overlap +295% vs vanilla, audio preservado (21.24% WER)
Hallazgos técnicos
- Qwen3.5 tiene tokens de audio nativos (
<|audio_start|>248070,<|audio_pad|>248076,<|audio_end|>248071) — el modelo fue diseñado para soportar audio masked_scatterunifica todas las modalidades — audio, imagen y video se inyectan con el mismo mecanismo en el espacio de embeddings- Muon optimizer converge ~35% más rápido que AdamW para projectors MLP de matrices 2D
- GRPO sobre LoRA compartido preserva audio — entrenar vision rewards no destruye capacidades de audio (+0.3pp WER)
- Bug de
rope_deltas: Qwen3.5 VL cachearope_deltasentre forward passes. Cuando TRL cambia batch size (generation vs logprob), produce tensor mismatch. Fix:register_forward_pre_hookque resetearope_deltas=None
Limitaciones
- Descripciones de visión cortas (~13 palabras vs 30-80 target) — el SFT con datos mixtos (transcripciones cortas) sesgó el modelo
- WER de audio 21% vs Whisper-small standalone ~12-15% — el AudioProjector MLP es un bottleneck
- Formato
<think>/<answer>no aprendido — GRPO no puede descubrir formatos que el modelo base nunca genera - Solo español — aunque Qwen3.5 es multilingüe, el entrenamiento fue exclusivamente en español
Requisitos
- Python 3.10+
- PyTorch 2.10+
- transformers >= 5.0 (necesario para Qwen3.5)
- VRAM: ~4GB (BF16 inference)
- Para audio:
openai/whisper-small,librosa
Cita
@misc{tinyomni-es-2026,
title={TinyOmni-ES: Unified Multimodal Encoder for Spanish Audio-Visual-Text Processing},
author={Ramses Camas},
year={2026},
url={https://huggingface.co/RamsesCamas/TinyOmni-ES-0.8B}
}
Licencia
Apache 2.0 (misma que Qwen3.5-0.8B)
- Downloads last month
- 5