Phi-3-mini feinjustiert: Produktdaten aus HTML als JSON extrahieren, lokal mit Ollama

Eigenes ModellFinetuningOllama · 2025
PythonUnslothHugging Face TRLPEFT/LoRAbitsandbytesllama.cppGGUFOllama

Projekt-Übersicht

In diesem Projekt habe ich das Sprachmodell Phi-3-mini von Microsoft für eine einzige, klar umrissene Aufgabe feinjustiert: Es bekommt einen HTML-Baustein mit Produktinformationen und gibt ein JSON-Objekt mit den Feldern name, preis, kategorie und marke zurück. Das Training läuft als QLoRA – das Basismodell bleibt in 4-Bit-Quantisierung eingefroren, trainiert werden nur zusätzliche LoRA-Adapter. Anschließend wird das Modell in das GGUF-Format exportiert, auf 4 Bit (Q4_K_M) quantisiert und in Ollama importiert, wo es lokal ohne Cloud-API läuft.

Der gesamte Ablauf steckt in einem Python-Skript mit deutschsprachiger Kommandozeile, einem Hilfsskript für die nachträgliche GGUF-Konvertierung und einem kleinen, selbst erstellten Datensatz. Alles liegt im öffentlichen Repository LargeLanguageModelsPublic im Ordner FinetuningOllama. Begleitend ist der Blogartikel zum Fine-Tuning mit Python und Ollama entstanden, der den Ablauf Schritt für Schritt erklärt.

Ausgangslage

Strukturierte Daten aus unstrukturiertem oder halbstrukturiertem Text zu ziehen, ist eine der häufigsten Aufgaben für Sprachmodelle in Agentensystemen: Ein Agent liest eine Webseite, eine E-Mail oder ein Dokument und muss daraus Felder füllen, mit denen der nächste Schritt weiterarbeitet. Große Cloud-Modelle lösen das zuverlässig, mit Prompting und JSON-Modus. Für viele Einsätze sind sie aber unpassend – wegen der Kosten pro Aufruf, wegen Datenschutzvorgaben oder weil die Verarbeitung offline laufen soll.

Die Frage hinter dem Projekt war daher: Wie viel Aufwand ist nötig, damit ein kleines, lokal lauffähiges Modell eine eng definierte Extraktionsaufgabe in einem festen Ausgabeformat übernimmt? Und wie sieht der vollständige Weg aus, vom Datensatz bis zum Modell, das man mit ollama run aufrufen kann?

Phi-3-mini bot sich als Basis an. Mit rund 3,8 Milliarden Parametern ist es klein genug, um in 4-Bit-Quantisierung auf einer Consumer-GPU trainiert und auf einem normalen Rechner ausgeführt zu werden, und als Instruct-Modell bringt es bereits ein Grundverständnis für Anweisungen und strukturierte Ausgaben mit.

Daten

Der Datensatz produktdaten_deutsch.json ist selbst erstellt und umfasst 49 Beispiele. Jedes Beispiel besteht aus einem Eingabetext und dem erwarteten JSON-Objekt:

{
  "input": "Extrahiere die Produktinformationen:\n<div class='produkt'><h2>Samsung Galaxy S24</h2><span class='preis'>€ 899</span><span class='kategorie'>Smartphone</span><span class='marke'>Samsung</span></div>",
  "output": {
    "name": "Samsung Galaxy S24",
    "preis": "€ 899",
    "kategorie": "Smartphone",
    "marke": "Samsung"
  }
}

Alle 49 Eingaben beginnen mit derselben Anweisung und folgen derselben HTML-Struktur: ein div mit der Klasse produkt, darin eine Überschrift für den Namen und drei span-Elemente für Preis, Kategorie und Marke, immer in dieser Reihenfolge. Variiert werden die Inhalte: Produkte aus Bereichen wie Smartphones, Kopfhörer, Smartwatches, Kaffeevollautomaten, Staubsauger, Kameras, Laptops oder Werkzeug, von 48 verschiedenen Marken in 34 Kategorien. Kategorien sind deutsch benannt, Preise werden als Zeichenkette mit Eurozeichen übernommen, nicht als Zahl. Jeder Wert im Ausgabeobjekt steht wörtlich in der Eingabe.

Es gibt keinen separaten Validierungs- oder Testsplit; alle 49 Beispiele fließen ins Training. Zur Kontrolle generiert das Skript nach dem Training eine Antwort auf ein Testprodukt, das nicht im Datensatz enthalten ist.

Modell und Training

flowchart LR
    A["produktdaten_deutsch.json, 49 Beispiele"] --> B["Prompt-Formatierung"]
    B --> C["Phi-3-mini in 4 Bit plus LoRA-Adapter"]
    C --> D["SFTTrainer, 3 Epochen"]
    D --> E["GGUF-Export BF16"]
    E --> F["Quantisierung Q4_K_M"]
    F --> G["Ollama mit Modelfile"]

Architekturwahl und Begründung

Das Basismodell ist unsloth/Phi-3-mini-4k-instruct-bnb-4bit, eine von Unsloth vorbereitete, mit bitsandbytes auf 4 Bit quantisierte Fassung von Phi-3-mini mit 4.096 Tokens Kontext. Geladen wird es über Unsloths FastLanguageModel, das für LoRA-Training optimierte Kernel mitbringt und den Speicherbedarf gegenüber einem Standard-Setup deutlich senkt.

Statt alle Gewichte zu trainieren, bekommt das Modell LoRA-Adapter: kleine Matrizen mit niedrigem Rang, die neben die bestehenden Gewichtsmatrizen gelegt werden. Ich setze sie an alle sieben Projektionsschichten jedes Transformer-Blocks – die vier der Attention und die drei des Feed-Forward-Netzes.

self.model = FastLanguageModel.get_peft_model(
    self.model,
    r=64,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_alpha=128,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing="unsloth",
    random_state=3407,
)

Bei Rang 64 auf allen Projektionen ergeben sich rund 119 Millionen trainierbare Parameter, etwa drei Prozent des Basismodells. Der Rang ist für eine so eng umrissene Aufgabe großzügig gewählt; er lässt dem Modell Spielraum, das Ausgabeformat fest zu verankern. Alpha liegt beim Doppelten des Rangs, eine verbreitete Wahl für die Skalierung der Adapter. Gradient Checkpointing in Unsloths Variante spart zusätzlich Speicher, indem Zwischenergebnisse im Rückwärtsdurchlauf neu berechnet statt gespeichert werden.

Hyperparameter

Trainiert wird mit dem SFTTrainer aus der Bibliothek TRL von Hugging Face:

args=TrainingArguments(
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    warmup_steps=10,
    num_train_epochs=3,
    learning_rate=2e-4,
    optim="adamw_8bit",
    weight_decay=0.01,
    lr_scheduler_type="linear",
    seed=3407,
    save_strategy="epoch",
)

Zwei Beispiele pro Durchlauf und vier Akkumulationsschritte ergeben eine effektive Batch-Größe von 8. Die Lernrate von 2e-4 ist für LoRA üblich – deutlich höher als beim vollständigen Fine-Tuning, weil nur die Adapter angepasst werden. Der Optimizer ist AdamW in einer 8-Bit-Variante, die den Speicherbedarf der Optimizer-Zustände reduziert. Gerechnet wird in bfloat16, wenn die GPU das unterstützt, sonst in float16. Die maximale Sequenzlänge liegt bei 2.048 Tokens, was für die kurzen Beispiele weit ausreicht.

Alle Parameter außer der Akkumulation lassen sich über die Kommandozeile ändern: --epochen, --batch-groesse, --lernrate, dazu --daten und --ausgabe für Pfade sowie --nur-test und --kein-export.

Trainingsablauf

Vor dem Training formatiert das Skript jedes Beispiel zu einem einzigen Text mit festen Markierungen für Eingabe und Ausgabe. Die Ausgabe wird mit ensure_ascii=False serialisiert, damit Umlaute und das Eurozeichen im JSON lesbar bleiben, und mit einem End-of-Text-Token abgeschlossen, damit das Modell lernt, nach dem JSON-Objekt aufzuhören.

def formatiere_prompt(self, beispiel):
    return (f"### Eingabe: {beispiel['input']}\n"
            f"### Ausgabe: {json.dumps(beispiel['output'], ensure_ascii=False)}"
            "<|endoftext|>")

Der Ablauf im Skript ist linear: Daten laden und formatieren, Modell in 4 Bit laden, LoRA-Adapter anlegen, trainieren, mit einem Testprodukt prüfen, als GGUF exportieren. Checkpoints werden nach jeder Epoche gespeichert, die letzten zwei bleiben erhalten. Bei 49 Beispielen und einer effektiven Batch-Größe von 8 umfasst eine Epoche sieben Optimierungsschritte, das gesamte Training also rund 20.

Für den Export nutzt das Skript Unsloths save_pretrained_gguf. Dabei werden die LoRA-Adapter in die Gewichte des Basismodells eingerechnet, das Ergebnis zunächst als GGUF in voller BF16-Präzision geschrieben und dann mit llama.cpp auf Q4_K_M quantisiert. Voraussetzung ist ein lokal gebautes llama.cpp. Weil dieser Schritt am häufigsten scheitert, gibt es ein separates Skript convert_to_gguf.py, das die Konvertierung aus einem gespeicherten Modell wiederholt und wahlweise auch Q5_K_M oder Q8_0 erzeugt.

Deployment

Das quantisierte Modell wird über ein Modelfile in Ollama importiert. Das Modelfile legt das Chat-Template von Phi-3 mit den Markierungen <|system|>, <|user|>, <|assistant|> und <|end|> fest, einen Systemprompt für die Extraktionsaufgabe und die Sampling-Parameter. Danach genügt ollama create produktextraktor -f Modelfile, und das Modell steht lokal über die Kommandozeile und die HTTP-API von Ollama zur Verfügung.

Als Sampling-Parameter setzt das Modelfile eine Temperatur von 0,3, top_p 0,9, top_k 40 und eine Wiederholungsstrafe (repeat_penalty) von 1,1, dazu die drei Stop-Tokens <|end|>, <|user|> und <|assistant|>. Diese Werte sind nicht zufällig gewählt. Kleine, feinjustierte Modelle neigen bei strukturierten Ausgaben zu zwei typischen Fehlerbildern: endlosen Wiederholungen oder vorzeitig abbrechendem JSON. Eine niedrige Temperatur, eine leichte Strafe für Wiederholungen und korrekt gesetzte Stop-Tokens wirken dem auf Inferenzseite entgegen. Ein direkter Import der GGUF-Datei ohne Modelfile ist zwar möglich, dann fehlen aber Template und Stop-Tokens.

Über die Ollama-API lässt sich das Modell aus jedem Programm heraus aufrufen, etwa als Werkzeug in einem Agenten, der Produktseiten verarbeitet und die extrahierten Felder weiterreicht.

Ergebnis

Das Ergebnis ist ein vollständiger, reproduzierbarer Ablauf vom eigenen Datensatz bis zum lokal laufenden Spezialmodell: Daten im JSON-Format, QLoRA-Training mit Unsloth und TRL, Export nach GGUF, Quantisierung auf Q4_K_M und Betrieb in Ollama. Der Code ist öffentlich, kommentiert und über Kommandozeilenparameter anpassbar, sodass sich der Ablauf mit einem anderen Datensatz für eine andere Extraktionsaufgabe wiederholen lässt.

Geprüft habe ich das Modell mit Stichproben aus Produkten, die nicht im Training vorkamen; eine systematische Auswertung über einen eigenen Testdatensatz war nicht Teil dieses Projekts. Zahlen zur Genauigkeit oder zur Trainingsdauer nenne ich deshalb nicht.

Mit 49 Beispielen in einer einzigen HTML-Struktur ist das Projekt als Machbarkeitsnachweis und Lernbeispiel zu verstehen. Für einen produktiven Einsatz auf echten Shopseiten mit wechselndem Markup bräuchte es deutlich mehr und vielfältigere Daten.

Was ich dabei gelernt habe

Trainingsformat und Inferenzformat müssen identisch sein. Ein Modell lernt nicht nur die Aufgabe, sondern auch die Struktur, in der sie ihm präsentiert wird – einschließlich der Markierungen und des Stop-Tokens. Am robustesten ist es, die Trainingsdaten mit apply_chat_template genau in dem Format aufzubereiten, in dem das Modell später angesprochen wird, also im selben Chat-Template, das auch im Modelfile steht.

Bei kleinen Datensätzen bestimmen die Schrittzahlen das Training mehr als die Epochen. Bei 49 Beispielen kommen nur rund 20 Optimierungsschritte zusammen. Warmup, Logging-Intervall und Checkpoint-Strategie plane ich deshalb in Schritten und passe sie an diese Zahl an, statt Standardwerte aus Anleitungen für große Datensätze zu übernehmen.

Qualität misst man an zurückgehaltenen Daten. Ein Stichprobentest zeigt, ob das Modell grundsätzlich funktioniert, aber nicht, wie oft es Fehler macht. Schon eine Handvoll zurückgehaltener Beispiele mit automatischem Vergleich der vier Felder liefert eine belastbare Zahl.

Gleichförmige Daten lehren ein Format, keine Fähigkeit. Weil alle Beispiele dieselbe HTML-Struktur haben und jeder Wert wörtlich in der Eingabe steht, lernt das Modell im Kern, vier Textstellen an festen Positionen zu kopieren. Das ist für ein Lernbeispiel in Ordnung. Für echte Extraktion braucht es Varianz im Markup, fehlende Felder, abweichende Schreibweisen von Preisen und Beispiele, in denen etwas nicht extrahiert werden soll.

Der Export ist der anfälligste Schritt. Training mit Unsloth funktioniert weitgehend reibungslos. Die GGUF-Konvertierung hängt dagegen an einem selbst gebauten llama.cpp und zusätzlichen Paketen. Ein separates Skript, das nur diesen Schritt aus einem gespeicherten Modell wiederholt, spart viel Zeit, wenn er scheitert.

Technologien

  • Python: Trainings- und Konvertierungsskripte mit deutschsprachiger Kommandozeile
  • Unsloth: schnelles Laden von Phi-3-mini in 4 Bit, LoRA-Adapter, GGUF-Export
  • Hugging Face TRL und Transformers: SFTTrainer und TrainingArguments für das Supervised Fine-Tuning
  • PEFT/LoRA und bitsandbytes: QLoRA mit 4-Bit-Basismodell und 8-Bit-AdamW
  • llama.cpp und GGUF: Konvertierung und Quantisierung auf Q4_K_M
  • Ollama: lokaler Betrieb mit Modelfile, Chat-Template und Sampling-Parametern
  • Phi-3-mini-4k-instruct: Basismodell von Microsoft