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_scatter en <|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

  1. Qwen3.5 tiene tokens de audio nativos (<|audio_start|> 248070, <|audio_pad|> 248076, <|audio_end|> 248071) — el modelo fue diseñado para soportar audio
  2. masked_scatter unifica todas las modalidades — audio, imagen y video se inyectan con el mismo mecanismo en el espacio de embeddings
  3. Muon optimizer converge ~35% más rápido que AdamW para projectors MLP de matrices 2D
  4. GRPO sobre LoRA compartido preserva audio — entrenar vision rewards no destruye capacidades de audio (+0.3pp WER)
  5. Bug de rope_deltas: Qwen3.5 VL cachea rope_deltas entre forward passes. Cuando TRL cambia batch size (generation vs logprob), produce tensor mismatch. Fix: register_forward_pre_hook que resetea rope_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
Safetensors
Model size
0.9B params
Tensor type
BF16
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for RamsesCamas/TinyOmni-ES-0.8B

Finetuned
(162)
this model
Quantizations
1 model