جميع المقالات
LLM Fine-Tuning

Fine-Tuning OpenAI's Whisper Model: A Complete Guide

O2Devs Team ٢٩ مايو ٢٠٢٦ 21 min read

So you've heard about Whisper - OpenAI's speech recognition model that seems to understand humans better than most humans understand each other. Maybe you've already played with it and thought: this is incredible, but it keeps messing up on my domain-specific terms, my accent, or my audio quality. If that's you, then this guide is exactly what you need. We're going to fine-tune Whisper on your own data, step by step, with no hand-waving.

Let's get into it.

What Is Whisper, Anyway?

Whisper is an automatic speech recognition (ASR) model released by OpenAI. It was trained on 680,000 hours of audio scraped from the internet - which is an almost incomprehensible amount of spoken data. As a result, it's remarkably robust out of the box. It handles different languages, accents, background noise, and even technical jargon reasonably well.

Whisper comes in several sizes: tiny, base, small, medium, and large. Bigger models are more accurate but slower and more resource-hungry. For most practical use cases, whisper-small or whisper-medium hits the sweet spot between speed and accuracy.

Under the hood, Whisper uses a Transformer-based encoder-decoder architecture - the same family of models that powers things like ChatGPT. It converts audio into mel spectrograms (a way of visually representing sound frequencies over time), encodes them, and then decodes them into text.

Why Fine-Tuning Is Necessary

Here's the honest truth: Whisper is great, but it's not perfect for every situation. The generic model was trained on internet audio, which means it's biased toward common speech patterns, clear pronunciation, and standard vocabulary.

If your use case involves any of the following, fine-tuning is almost certainly worth it:

  • Industry-specific language - medical terminology, legal jargon, engineering terms, product names
  • Regional accents or dialects - if your users have a strong accent that differs from standard American or British English
  • Noisy environments - call centers, field recordings, or any setting where audio quality is inconsistent
  • A specific language or mix of languages - especially low-resource languages that weren't well-represented in the training data
  • Short or conversational utterances - Whisper sometimes struggles with very short phrases or casual speech patterns

Fine-tuning teaches the model your specific world. It's the difference between hiring a general assistant and hiring someone who already knows your industry inside and out.

How to Prepare Your Dataset

Before writing a single line of training code, you need data. This is where most people get stuck - not because it's complicated, but because it requires patience and attention to detail.

The CSV Format

Your dataset should be a CSV file with two columns:

audios,transcription
audios/1.mp3,hi how are you
audios/2.mp3,my name is john and i work at the hospital
audios/3.mp3,the patient showed signs of hypertension

The audios column holds the file path to each audio clip. The transcription column holds the exact text that is spoken in that clip.

The Folder Structure

Your project directory should look like this:

project/
│
├── final_generated_dataset.csv
└── audios/
    ├── 1.mp3
    ├── 2.mp3
    └── 3.mp3

Tips for Good Training Data

  • Quality over quantity - 500 clean, accurate transcriptions will beat 5,000 sloppy ones every single time
  • Diversity matters - include variation in speakers, recording conditions, and sentence structures
  • Be consistent - decide upfront whether your transcriptions include punctuation or not, and stick to it
  • Audio length - aim for clips between 2 and 25 seconds. Whisper was designed for this range
  • Sampling rate - your audio should be at 16kHz, or you should ensure the code resamples it (we handle this below)

The Full Training Code - Explained Like a Human

Now for the main event. Here's the complete fine-tuning script, broken down section by section so you understand exactly what's happening and why.

Step 1: Importing the Tools

import os
import re
import torch
import librosa
import evaluate
import numpy as np

from dataclasses import dataclass
from typing import Any, Dict, List, Union

from datasets import load_dataset, Audio
from transformers import (
    WhisperProcessor,
    WhisperTokenizer,
    WhisperFeatureExtractor,
    WhisperForConditionalGeneration,
    Seq2SeqTrainer,
    Seq2SeqTrainingArguments,
    EarlyStoppingCallback
)
from audiomentations import Compose, AddGaussianNoise, Gain

Think of this section as gathering your tools before starting a construction project. Here's what each tool does:

  • torch - PyTorch, the deep learning framework that runs everything. It's the engine under the hood.
  • librosa - A specialized library for audio processing. We use it to trim silence from audio clips.
  • evaluate - Hugging Face's library for computing metrics. We use it to calculate Word Error Rate (WER), the standard way to measure transcription quality.
  • numpy - The go-to library for numerical operations in Python. Handles array math efficiently.
  • datasets - Hugging Face's library for loading and managing datasets. It handles caching, streaming, and format conversion for us.
  • transformers - The Hugging Face library that gives us access to Whisper and its training utilities.
  • audiomentations - A library specifically for augmenting audio data. We use it to artificially add noise and vary volume, making the model more robust.

Step 2: Loading Your CSV Dataset

import pandas as pd
from datasets import Dataset, Audio

df = pd.read_csv("final_generated_dataset.csv")

df = df.rename(columns={
    "path": "audio",
    "transcription": "text"
})

dataset = Dataset.from_pandas(df)

This section reads your CSV file and converts it into a Hugging Face Dataset object.

The rename step is important - we're standardizing the column names so that the rest of the pipeline can find them without confusion. If your CSV already has columns named audio and text, you can skip the rename.

The Dataset.from_pandas() call converts the regular pandas DataFrame into a Hugging Face Dataset, which gives us a lot of useful features like caching, fast filtering, and parallel processing.

Step 3: Casting the Audio Column

dataset = dataset.cast_column(
    "audio",
    Audio(sampling_rate=16000)
)

Right now, the audio column just contains file paths (strings like "audios/1.mp3"). This line tells Hugging Face to actually load those audio files and resample them to 16,000 Hz when accessed.

Why 16kHz? Because that's what Whisper expects. Audio sampled at a different rate would be like playing a record at the wrong speed - the model would hear something distorted and produce garbage output.

This is a lazy operation - the audio isn't actually loaded yet, it just knows how to load it when needed. That keeps memory usage manageable.

Step 4: Train/Test Split

split_dataset = dataset.train_test_split(test_size=0.1, seed=42)

train_dataset = split_dataset["train"]
test_dataset = split_dataset["test"]

We split the data into a training set (90%) and a test set (10%). The training set is what the model learns from. The test set is held back and used to evaluate how well the model actually generalised - meaning, does it work on data it hasn't seen before?

Setting seed=42 ensures the split is reproducible. If you run this again tomorrow, you'll get the same split. This matters for debugging and comparison.

Step 5: Device Configuration

device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using device:", device)

This checks whether you have a GPU available. If you do, training will run on the GPU (dramatically faster - we're talking 10x to 50x). If not, it falls back to the CPU.

Training Whisper on a CPU is technically possible but painfully slow. For anything more than a toy dataset, you really want a GPU. Google Colab or a cloud provider like AWS, GCP, or RunPod are good options if you don't have local GPU hardware.

Step 6: Configuration

model_id = "openai/whisper-small.en"
output_dir = "whisper-medium-small-en"
num_epochs = 5
batch_size = 4
gradient_accumulation_steps = 16
learning_rate = 1e-5

These are your training hyperparameters - the dials you can turn to control how training behaves:

  • model_id - Which pre-trained Whisper model to start from. whisper-small.en is English-only and smaller, which trains faster.
  • output_dir - Where the fine-tuned model will be saved when training is done.
  • num_epochs - How many times the model will pass through your entire dataset. More epochs can improve accuracy but risk overfitting.
  • batch_size - How many samples are processed at once. Larger batches are more stable but require more GPU memory.
  • gradient_accumulation_steps - A memory trick. With batch_size=4 and accumulation=16, the effective batch size is 64. The model updates its weights after every 16 mini-batches, simulating a larger batch without needing the memory for it.
  • learning_rate - How big the steps are when the model adjusts its weights. 1e-5 is a conservative, safe choice for fine-tuning.

Step 7: Filtering Bad Samples

def is_valid(example):
    text = example["text"].strip()
    audio_length = len(example["audio"]["array"]) / 16000
    return (
        len(text) > 5
        and audio_length > 1.0
        and audio_length < 30
    )

train_dataset = train_dataset.filter(is_valid)
test_dataset = test_dataset.filter(is_valid)

This is a quality-control gate. Before training, we throw out any samples that are likely to cause problems:

  • Transcriptions with 5 characters or fewer are probably empty or garbage
  • Audio clips shorter than 1 second don't give the model enough to work with
  • Audio clips longer than 30 seconds exceed Whisper's context window and would be truncated anyway, causing mismatches between audio and text

Think of it as cleaning your kitchen before cooking. You wouldn't start a recipe with spoiled ingredients.

Step 8: Text Normalization

def normalize_text(text):
    text = text.lower()
    text = re.sub(r"[^a-z0-9\s]", "", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text

This function standardizes all the transcriptions before the model learns from them. Here's what each line does:

  • .lower() - Converts everything to lowercase. "Hello" and "hello" should be the same thing to the model.
  • re.sub(r"[^a-z0-9\s]", "", text) - Removes all characters that aren't letters, numbers, or spaces. Punctuation like commas, periods, and apostrophes are stripped out.
  • re.sub(r"\s+", " ", text).strip() - Collapses multiple spaces into one, and removes leading/trailing whitespace.

The result is clean, consistent text that the model can learn from without being confused by inconsistent formatting.

Step 9: Loading the Whisper Processor

feature_extractor = WhisperFeatureExtractor.from_pretrained(model_id)

tokenizer = WhisperTokenizer.from_pretrained(
    model_id,
    language="English",
    task="transcribe"
)

processor = WhisperProcessor.from_pretrained(
    model_id,
    language="English",
    task="transcribe"
)

These three components together form the bridge between raw data and what the model sees:

  • WhisperFeatureExtractor - Converts raw audio waveforms into mel spectrograms, which are 2D visual representations of sound. This is what the model's encoder actually processes.
  • WhisperTokenizer - Converts text into token IDs (numbers) that the model's decoder can work with. Each word or sub-word gets a unique number.
  • WhisperProcessor - A convenience wrapper that combines both of the above into a single object.

Step 10: Audio Augmentation

augment = Compose([
    AddGaussianNoise(min_amplitude=0.0005, max_amplitude=0.003, p=0.2),
    Gain(min_gain_db=-2, max_gain_db=2, p=0.2)
])

Data augmentation is one of the most powerful techniques in machine learning. The idea is simple: artificially create variations of your training data so the model learns to handle real-world imperfections.

  • AddGaussianNoise - Randomly adds a tiny amount of static/noise to 20% of audio clips (the p=0.2 means 20% probability). This teaches the model to work in noisy environments.
  • Gain - Randomly makes audio slightly louder or quieter. This teaches the model not to rely on a specific volume level.

These augmentations are applied only during training, never during evaluation.

Step 11: Audio Preprocessing

def preprocess_audio(audio_array):
    audio_array, _ = librosa.effects.trim(audio_array, top_db=30)
    if np.max(np.abs(audio_array)) > 0:
        audio_array = audio_array / np.max(np.abs(audio_array))
    return audio_array.astype(np.float32)

Before the model touches any audio, we run it through this preprocessing function:

  • librosa.effects.trim - Removes silence from the beginning and end of the clip. That dead air before and after someone starts speaking is just noise that wastes context window and confuses the model.
  • Volume normalization - Scales the audio so its peak volume is always 1.0. This ensures the model isn't thrown off by some clips being much louder or quieter than others. It's like making sure everyone in a room is speaking at a consistent volume before you try to understand them.

Step 12: Dataset Preparation Functions

def prepare_train_dataset(batch):
    audio = batch["audio"]
    audio_array = audio["array"]
    audio_array = preprocess_audio(audio_array)
    audio_array = augment(samples=audio_array, sample_rate=16000)

    batch["input_features"] = feature_extractor(
        audio_array, sampling_rate=16000
    ).input_features[0]

    text = normalize_text(batch["text"])
    batch["labels"] = list(map(int, tokenizer(text).input_ids))
    return batch


def prepare_eval_dataset(batch):
    audio = batch["audio"]
    audio_array = audio["array"]
    audio_array = preprocess_audio(audio_array)

    batch["input_features"] = feature_extractor(
        audio_array, sampling_rate=16000
    ).input_features[0]

    text = normalize_text(batch["text"])
    batch["labels"] = list(map(int, tokenizer(text).input_ids))
    return batch

These two functions transform raw data into the format the model expects. The train version includes augmentation; the eval version does not - you always want to evaluate on clean data to get an honest measure of performance.

The output of each function is:

  • input_features - The mel spectrogram of the audio, ready for the encoder
  • labels - The tokenized text, ready for the decoder to learn from

Step 13: The Data Collator

@dataclass
class DataCollatorSpeechSeq2SeqWithPadding:
    processor: Any
    decoder_start_token_id: int

    def __call__(self, features):
        input_features = [{"input_features": f["input_features"]} for f in features]
        batch = self.processor.feature_extractor.pad(input_features, return_tensors="pt")

        label_features = [{"input_ids": f["labels"]} for f in features]
        labels_batch = self.processor.tokenizer.pad(label_features, return_tensors="pt")

        labels = labels_batch["input_ids"].masked_fill(
            labels_batch.attention_mask.ne(1), -100
        )

        batch["labels"] = labels
        return batch

This is one of the trickiest pieces of the pipeline to understand, but it's critically important.

When training in batches, all samples in a batch need to be the same length. But audio clips and transcriptions are naturally different lengths. The data collator's job is to pad shorter sequences with zeros (for audio) or -100 (for labels) so everything lines up.

Why -100 for label padding? Because PyTorch's cross-entropy loss function has a special behavior: it ignores any position where the label is -100. This means the model won't be penalized for not predicting the padding tokens - which would be completely unfair and counterproductive.

Step 14: Loading the Model

model = WhisperForConditionalGeneration.from_pretrained(model_id)
model.to(device)

model.generation_config.forced_decoder_ids = None
model.generation_config.suppress_tokens = []
model.config.use_cache = False
model.gradient_checkpointing_enable()

Here we load the pre-trained Whisper model and configure it for fine-tuning:

  • .to(device) - Moves the model to the GPU (if available)
  • forced_decoder_ids = None - Removes any hardcoded language/task tokens so the model can learn from our data freely
  • suppress_tokens = [] - Clears the list of tokens the model was told never to generate. Again, we want it to learn from our data without artificial constraints.
  • use_cache = False - Disables KV-cache during training (it's only useful during inference)
  • gradient_checkpointing_enable() - A memory-saving trick. Instead of storing all intermediate computations, it recomputes them during the backward pass. Uses more compute time but significantly less GPU memory.

Step 15: The Evaluation Metric

metric = evaluate.load("wer")

def compute_metrics(pred):
    pred_ids = pred.predictions
    label_ids = pred.label_ids

    if isinstance(pred_ids, tuple):
        pred_ids = pred_ids[0]

    if len(pred_ids.shape) == 3:
        pred_ids = np.argmax(pred_ids, axis=-1)

    label_ids = np.where(label_ids != -100, label_ids, tokenizer.pad_token_id)

    pred_str = tokenizer.batch_decode(pred_ids, skip_special_tokens=True)
    label_str = tokenizer.batch_decode(label_ids, skip_special_tokens=True)

    pred_str = [normalize_text(x) for x in pred_str]
    label_str = [normalize_text(x) for x in label_str]

    wer = 100 * metric.compute(predictions=pred_str, references=label_str)
    return {"wer": wer}

Word Error Rate (WER) is the standard benchmark for speech recognition quality. It's calculated as:

WER = (Substitutions + Insertions + Deletions) / Total Words in Reference

A WER of 0% means perfect transcription. A WER of 10% means 1 in every 10 words is wrong. Anything below 10% is generally considered good for production use.

This function converts the model's raw predictions and the ground truth labels back into readable text, normalizes both, and then computes WER. This runs at the end of every epoch so you can watch your model improve over time.

Step 16: Training Arguments

training_args = Seq2SeqTrainingArguments(
    output_dir=output_dir,
    per_device_train_batch_size=batch_size,
    per_device_eval_batch_size=batch_size,
    gradient_accumulation_steps=gradient_accumulation_steps,
    learning_rate=learning_rate,
    warmup_ratio=0.05,
    num_train_epochs=num_epochs,
    eval_strategy="epoch",
    save_strategy="epoch",
    logging_steps=50,
    predict_with_generate=True,
    generation_max_length=128,
    fp16=True,
    save_total_limit=2,
    load_best_model_at_end=True,
    metric_for_best_model="wer",
    greater_is_better=False,
    lr_scheduler_type="cosine",
    weight_decay=0.01,
    seed=42,
    report_to=["tensorboard"]
)

Let's walk through the important ones:

  • warmup_ratio=0.05 - The model starts with a very small learning rate and gradually increases it over the first 5% of training steps. This prevents the model from making wild, destructive updates early on when it's still orienting itself.
  • eval_strategy="epoch" - Evaluate on the test set at the end of every epoch. This is how we track improvement.
  • fp16=True - Uses 16-bit floating point arithmetic instead of 32-bit. Cuts memory usage nearly in half and speeds up training on modern GPUs.
  • load_best_model_at_end=True - After training finishes, automatically loads the checkpoint with the lowest WER, not necessarily the last one. This is important - training isn't always monotonically improving.
  • metric_for_best_model="wer" and greater_is_better=False - These tell the trainer that lower WER is better (we want fewer errors, not more).
  • lr_scheduler_type="cosine" - The learning rate follows a cosine curve, starting high and gradually cooling down. This often leads to better final models than keeping a constant learning rate.
  • weight_decay=0.01 - A regularization technique that prevents the model from overfitting by penalizing very large weight values.

Step 17: The Trainer and Training

trainer = Seq2SeqTrainer(
    args=training_args,
    model=model,
    train_dataset=train_dataset,
    eval_dataset=test_dataset,
    data_collator=data_collator,
    compute_metrics=compute_metrics,
    callbacks=[EarlyStoppingCallback(early_stopping_patience=3)]
)

trainer.train()

The Seq2SeqTrainer is the orchestrator that ties everything together. It handles the training loop, gradient updates, evaluation, checkpointing, and logging - so you don't have to write any of that yourself.

The EarlyStoppingCallback(early_stopping_patience=3) is a safeguard: if the model's WER hasn't improved for 3 consecutive epochs, training automatically stops. This prevents wasting compute time on a model that's already peaked or has started to degrade.

Step 18: Saving and Final Evaluation

trainer.save_model(output_dir)
processor.save_pretrained(output_dir)
print("Training complete.")

final_metrics = trainer.evaluate()
print(final_metrics)

After training completes, we save both the model weights and the processor to the output directory. You need to save the processor too - it contains the tokenizer and feature extractor configuration that you'll need when loading the model later for inference.

The final evaluation gives you a clean snapshot of the model's performance on the held-out test set.

What to Expect During Training

During training, you'll see logs that look something like this:

{'loss': 2.4521, 'learning_rate': 8.5e-06, 'epoch': 1.0}
{'eval_loss': 1.8234, 'eval_wer': 32.5, 'epoch': 1.0}
{'eval_loss': 1.2145, 'eval_wer': 18.2, 'epoch': 2.0}
{'eval_loss': 0.9876, 'eval_wer': 12.7, 'epoch': 3.0}

You want to see eval_wer going down over time. If it starts going back up and stays up for 3 epochs, early stopping will kick in.

A WER below 15% is a solid result for most fine-tuning projects. Below 10% is excellent. Below 5% means you've either got great data or you're working in a constrained enough domain that the task is genuinely achievable.

Common Mistakes to Avoid

  • Messy transcriptions - Inconsistent punctuation, spelling errors, or wrong words will directly hurt your model. Clean data is everything.
  • Too little data - Fine-tuning generally needs at least a few hundred samples to show meaningful improvement. More is better.
  • Skipping normalization - If you don't normalize text, the model will waste capacity learning that "Hello," and "hello" are somehow different things.
  • Not saving the processor - If you forget processor.save_pretrained(), you won't be able to reload your model correctly later.
  • Evaluating with augmentation - Always make sure augmentation is only applied during training, never evaluation.

Wrapping Up

Fine-tuning Whisper is one of the most practical things you can do if you're building a voice-powered product in a specialized domain. The base model gives you a phenomenal starting point, and with even a few hundred quality examples, you can dramatically improve accuracy for your specific use case.

The pipeline we've walked through handles everything: data loading, audio preprocessing, augmentation, normalization, training, evaluation, and saving. You can drop in your own CSV and audio files and run it end to end.

If you found this useful, the next natural steps are experimenting with different model sizes (whisper-medium or whisper-large-v3 for higher accuracy), trying more aggressive augmentation, or exploring quantization to make your fine-tuned model faster for production deployment.

If you are looking for help fine-tuning Whisper or building voice-powered applications for your business, we are happy to talk it through. A 30-minute conversation is usually enough to give you a clear direction.

تحتاج مساعدة لتطبيق هذا في أعمالك؟

نعمل مع شركات في الخليج والولايات المتحدة وأوروبا. لنتحدث عن وضعك المحدد.

ابدأ محادثة