Per E-Mail bekomme ich pro Tag gut 30 Spam-Nachrichten. Der Spam-Filter meines Webhosters ist faktisch unbrauchbar. Der Filter in Thunderbird ist besser, hat aber auch seine Lücken. Ich habe versucht, das mit einem Large Language Model zu lösen – am Ende war die Lösung allerdings, in Thunderbird einige Nicht-Spam als solche zu markieren, um den integrierten Filter zu verbessern.

In 14 Tagen kamen in meinem Postfach 252 Spam-Nachrichten an. Davon ist eigentlich alles sehr klar direkt als Spam erkennbar:

Aktuell bin ich bei einem Webhoster, der eher das unterste Preissegment anbietet. Daher bleibt kein Geld für die Anti-Spam-Liste von Spamhaus. An sich ist das verständlich, man kann nur das bekommen, wofür man zahlt.

Filter im Client

An sich sollte ein Spam-Filter auf dem Server laufen. Die E-Mails sollten sortiert werden, bevor sie überhaupt erst im Posteingang ankommen. Daher ist alles, was ich hier nachträglich bastle, an sich schon ein Konstruktionsfehler.

Thunderbird, das ich auf dem Laptop nutze, hat einen Spam-Filter eingebaut. Der schaut sich die neuen Nachrichten im Posteingang an und klassifiziert anhand von Heuristiken und vielleicht auch gelernt anhand meiner eigenen Klassifikationen. Das klappt okay, aber so richtig gut klappt das leider nicht.

Selbst wenn ich das jetzt mit einem eigenen Spam-Filter löse, funktioniert das nur, solange der auch läuft. Auf dem Handy kommen die E-Mails erstmal im Posteingang an und lösen eine Benachrichtigung aus. Von daher ist das alles nur Kompromiss.

Die echte Lösung wäre, den E-Mail-Anbieter zu wechseln und auf einen vernünftigen Spam-Filter zu achten. Aber bis dahin kann ich ja noch was basteln.

Idee

Große Sprachmodelle (LLMs) können ja erstaunlich viele Dinge. Und Spam-Klassifizieren ist wohl auch nicht weiter schwer. Ich habe eine Spam-E-Mail mit einem entsprechenden Prompt einmal in Claude Haiku 4.5 geworfen, dem kleinsten Modell des Anbieters:

Das Teil wollte sich wohl nicht kurzfassen, jedenfalls geht es genau auf die richtigen Dinge ein:

I’ll read the uploaded email file to analyze it for spam.Now let me read the email content:## Analysis

Reason: This is a phishing/scam email impersonating Hornbach (a legitimate German retailer). Multiple red flags:

  1. Email spoofing: Sent from support@nicovideo.jp (a Japanese video platform) but claims to be from “Christian von Hornbach” and Hornbach company
  2. SPF failure: The email fails SPF authentication (softfail), indicating the sender is not authorized
  3. Suspicious URL pattern: All links point to incstorage11.storage.googleapis.com (generic Google Cloud Storage), not Hornbach’s domain
  4. Phishing tactics:
    • Urgency (“only 25 devices left”)
    • Artificial scarcity
    • Impersonation of known brand
    • High discount offer (50%) as bait
  5. Placeholder variable: Contains {EMAIL} instead of actual email address, suggesting mass-mailing templates
  6. Domain mismatch: Return-path shows nicovideo.jp, not Hornbach

Probability: 0.98

This is almost certainly spam/phishing. The only reason it’s not 1.0 is the possibility of an extremely compromised Hornbach system, but the evidence strongly points to credential theft or domain spoofing.

Also das funktioniert an sich.

Man könnte auch noch einen Classifier trainieren, der wirklich eine Wahrscheinlichkeit ausgibt. Aber ich mag dafür kein Modell trainieren, ich versuche das nur mal mit einem Prompt für ein bestehendes Modell.

Der Ansatz ist also ziemlich einfach: Ein Programm geht alle neuen E-Mails im Posteingang durch. Dann schickt es die E-Mail mit einem Prompt an ein LLM. Anhand der Antwort kann das Programm zwischen Spam und Ham (Nicht-Spam) entscheiden. Spam wird in den Spam-Ordner verschoben.

Die Kosten für die API hier würden ziemlich überschaubar sein. Aber ich wollte das mit einem lokalen LLM ausprobieren, damit hier keine weiteren Kosten ausgelöst werden. Kleine Modelle sollten auch gut ausreichen. Das wollte ich dann auch einmal statistisch untersuchen.

Erstellung durch Coding Agent

Auch wenn es irgendwo schade ist, schreibe ich nur noch selten Code per Hand. Und so habe ich das hier auch gemacht. Ich habe nur ein leeres Python-Projekt mit uv angelegt und dann die README geschrieben:

My mail provider’s spam filter sucks and I rely on the spam filter in Thunderbird. That isn’t ideal, but better than nothing. Thunderbird’s filter is okay, but a small LLM should be even better.

This is a Python project that connects to a server via IMAP and moves spam into the spam folder. It runs the e-mail content (without attachments) through a local LLM (like a Qwen one) via Ollama.

It also has a testing script where it looks at all the e-mails from the spam folder and a sample from the archive folders to generate a ROC curve of various LLMs with different prompts.

Config works via a TOML file where one gives the IMAP credentials.

Das Ganze habe ich dann per Claude Code an Opus 5 High gegeben und das Teil machen lassen. Dabei kam dann ein durchaus sinnvoller erster Stand raus. Ich habe noch ein paar Details korrigieren lassen. Generell machte es direkt das, was ich haben wollte.

Prompts

Mein Coding Agent hat auch direkt schon Prompts für das andere LLM erzeugt. Langsam erinnert mich das an Ouroboros oder eine Matroschka-Puppe. Ich habe einen Prompt für ein LLM erzeugt, damit wurde ein Programm erzeugt, das einen Prompt für ein anderes LLM enthält.

Dabei hat es direkt drei verschiedene Prompts ausprobiert, die durchgetestet werden können.

Prompt „concise":

You are a spam filter. Read the e-mail and estimate the probability that it is spam.

Spam is unsolicited bulk mail: advertising, phishing, scams, adult content, cryptocurrency or investment pitches, fake invoices, and malware delivery.

Not spam: personal and work correspondence, and mail the recipient signed up for, such as newsletters, order confirmations, shipping notices, invoices, bank statements, calendar invitations, and notifications from services and mailing lists.

Answer with a short reason and a probability between 0 and 1.

Prompt „detailed":

You are the spam filter of an experienced computer scientist. Decide whether an e-mail is unsolicited bulk mail.

Evidence for spam:

  • The sender address does not match the organization it claims to be from.
  • Urgency or threats: accounts closing, payments overdue, legal consequences.
  • Requests for credentials, payment details, or replies to a different address.
  • Prizes, inheritances, dating offers, cheap medication, replica goods.
  • Investment, cryptocurrency, or SEO and marketing cold outreach.
  • Obfuscated text, random character sequences, or mismatched link targets.

Evidence against spam:

  • The recipient is addressed by name in an ongoing conversation.
  • Transactional mail referring to a concrete order, ticket, or account the recipient holds.
  • Newsletters and mailing lists with a working unsubscribe link and consistent sender.
  • Automated reports from software the recipient runs.

Cold commercial outreach that is addressed personally is still spam. A newsletter the recipient subscribed to is not spam, even if it is advertising.

Answer with a short reason and a probability between 0 and 1.

Prompt „rubric":

You are a spam filter. Score the e-mail on this scale and report it as a probability:

0.0-0.2 Clearly wanted: personal mail, work mail, transactional mail about the
recipient’s own accounts, subscribed newsletters and mailing lists.
0.3-0.5 Unclear: commercial mail whose subscription status cannot be determined.
0.6-0.8 Probably unwanted: cold sales outreach, bulk advertising from unknown senders.
0.9-1.0 Certainly unwanted: phishing, scams, malware, adult content, obvious fraud.

Judge the message itself, not the fact that it is automated. Answer with a short reason and the probability.

Performance auf meinem Laptop

Das Ganze läuft mit Ollama auf meinem Laptop. Die Modellkandidaten, die ich ausprobiert habe, sind qwen3.5:0.8b, qwen3.5:2b-q4_K_M, und qwen3.5:4b-q4_K_M. Bei denen sind die Modellgewichte 0,8 GB, 1 GB, bzw. 2 GB groß. Das passt mit meinen 16 GB RAM einigermaßen.

Hier ist qwen3.5:0.8b gerade dran. Ollama zeigt komplette Auslastung aus und 1,1 GB mit den nötigen weiteren Daten (Aktivierungen, KV Cache):

❯ ollama ps
NAME            ID              SIZE      PROCESSOR    CONTEXT    UNTIL              
qwen3.5:0.8b    f3817196d142    1.1 GB    100% CPU     4096       4 minutes from now

In diesem Laptop ist ein Intel Core i5-10210U (8) @ 4.20 GHz verbaut. Dabei laufen immer jeweils zwei Threads auf den vier physischen Kernen. Ollama ist hier schlau genug, um nur mit vier und nicht acht Threads zu rechnen. Das sieht man in htop ganz hübsch, da steht nur 375 % (mit 100 % pro Kern) bei der CPU-Auslastung:

    1[|||||||||||||||||||||||||||||||||||     74.1%] Tasks: 212, 968 thr, 181 kthr; 5 running
    2[|||||||                                 11.7%] Load average: 3.92 2.39 1.19 
    3[||||||||||||||||||||||||||||||||        64.2%] Uptime: 00:10:30
    4[||||||||||||||||||||||||||              56.2%]
    5[|||||||||||||||||||                     38.9%]
    6[|||||||||||||||||||||||||||||||||||||||100.0%]
    7[||||||||||||||||||||                    40.4%]
    8[||||||||||||||||||||||                  46.6%]
  Mem[||||||||||||||||||||||||||||||||||9.56G/15.3G]
  Swp[||                                14.2M/8.00G]

    PID USER        NI S  CPU%▽MEM%  VIRT   RES   SHR   TIME+  Command (merged)
  12278 root         0 R 375.9 13.5 3459M 2110M 12420  6:55.03 llama-server --model /var/snap/ollama/comm

Arbeitsspeicher sind 2,1 GB, da ist auch nicht mehr so viel Platz. Mir ist der Laptop auch schon einmal eingefroren, weil der RAM voll war. Und das ist noch das kleinste Modell!

Davon ab hat das qwen3.5:0.8b zwanzig Minuten für 100 Nachrichten gebraucht. Das sind also 15 Sekunden pro Nachricht. Im Betrieb wäre das schon okay, jedoch ist das für die Evaluation mit je 400 E-Mails pro Modell und Prompt schon eher eine fettere Aufgabe.

Gaming-PC

Weil ich nicht so lange warten wollte, habe ich Ollama auf meinem Windows-Gamingrechner angeworfen. Das hat auch eine grafische Oberfläche, deutlich reduziert gegenüber LM Studio, für meinen Einsatz als API-Server aber genau richtig.

Dann hat sich das Teil erstmal das Modell heruntergeladen, die Dinger sind ja relativ groß.

Dann war es fertig. Gerechnet wurde nicht mehr auf meiner Laptop-CPU mit vielleicht 20 W, sondern auf der Grafikkarte mit eher so 200 W.

Damit gingen dann 100 Nachrichten in 77 Sekunden. Also gut einen Faktor 20 schneller, das ist deutlich angenehmer. Grafikkarten sind schon wirklich leistungsstark für so etwas.

Einzelne Ergebnisse

Schaut man sich die Ergebnisse von qwen3.5:0.8b mit concise Prompt für 10 Spam-Nachrichten an, ist es irgendwie erheiternd.

Is spam Subject Sender Spam probability Reason
True Status-Update: Mehr Leidenschaft, mu Versand-Apotheke <meyer@defined.btctoolco.com> 0.15 The email is a direct marketing advertisement for pharmaceutical products (hence ‘Bestseller’, ‘medizinische Mittel’) and offers free medical advice via an online clinic. It contains no phishing attempts, malware delivery instructions, or unsolicited bulk advertising beyond the standard scope of personal correspondence.
True Your cloud drive has stopped updating. Your storage is at 100 percent Backup Center <support@masterorganicchemistry.com> 0 This is a technical notification regarding storage usage and an upgrade offer for the user’s cloud drive. It contains no phishing links, malware indicators (no malicious attachments or code), adult content, cryptocurrency scams, fake invoices, unsolicited bulk advertising, or personal correspondence.
True Complete our short survey for a Medicare Kit MyChart <support@poppylist.com> 0.95 This is a highly suspiciously crafted email designed to deceive recipients into signing up for an unregistered medical kit, which could lead to malware delivery or phishing. The subject line ‘Complete our short survey’ and the claim of receiving a free premium first-aid kit are standard tactics used in spam campaigns targeting healthcare providers.
True 수령한 제품의 상태를 확인해 주세요 양지은 <support@bayown.com> 0 이메일은 구매자로부터 받은 상품 수령 후 상태를 확인한 개인적/업무용 통보입니다. ‘파손된 부위를 촬영한 사진’과 함께 교환/환불 절차 안내를 요청하는 것은 정상적인 반품 처리 절차를 위한 것입니다.
True Aktualisierung der photoTAN-App – Bitte führen Sie die Aktivierung durch Commerzbank AG <info@commerzbank.com> 0 This is a legitimate security update notification from Commerzbank AG, informing the recipient that their photoTAN app needs to be activated for enhanced data protection and online access.
True Reclaim your sharp mind without medication CBS Health Desk <support@justice.gov.lb> 0.98 This email is a direct advertisement for the ‘Harmo Brain Protocol,’ an unproven natural remedy targeting cognitive decline. It explicitly warns that powerful interest groups are attempting to take down this video, which constitutes phishing and malicious distribution of harmful content.
True Auf deinem Konto ist eine Zahlung eingegangen! mu@martin-ueding.de 0.98 This email is highly suspicious due to the explicit content (pornographic material), phishing attempts disguised as a payment request for malware removal, and false claims of technical expertise.
True Re: Financing Offer First Gulf Investments Solutions <hussainbat@yandex.com> 0.95 This email is highly suspicious due to the explicit request to contact via WhatsApp (a common scam tactic) and references of ‘finders’ percentages, which are typical indicators for phishing or fraudulent fundraising schemes. The subject line mentions a specific interest rate that may be misleading.
True Das Schlaf-Upgrade, das Deutschland begeistert Lunavelle Team <axjackz@myshipping.rent> 0.98 The email is a promotional advertisement for sleep supplements and natural melatonin therapy in Germany.
True Wegovy ohne Spritze? Diese Kapsel schmilzt Fett Stoffwechsel-Report <support@weblinks.ru> 0.95 This is a highly suspiciously crafted email designed to deceive recipients into purchasing fake medical supplements. The subject line uses hyperbolic language (‘absolute Sensation’, ‘million-dollar investment’) and references fictional TV shows, which are common tactics in phishing scams (often called ‘fake news’ or ‘scam emails’). It explicitly mentions the brand name WEGO6 as if it is a real drug for weight loss, but describes its mechanism as natural and safe. The email claims to be from an official medical department (‘Medizin-Redaktion’) of a fictional show, which creates credibility issues. While not malicious in itself (it’s just fake), the probability that this specific text constitutes spam due to deceptive marketing is extremely high.

Die erste Nachricht mit dem Betreff „Mehr Leidenschaft, mu“ ist doch ganz klar Potenzmittelwerbung. Aber das Modell erklärt, warum das nicht Spam sei. Auch fällt es auf die Nachricht mit dem Betreff „Your cloud drive has stopped updating. Your storage is at 100 percent“ rein. Der Absender, „Backup Center <support@masterorganicchemistry.com>“ ist doch auch ziemlich offensichtlich Quatsch.

Interessant ist die E-Mail mit dem koreanischen Betreff. Da hat das Modell dann eine Antwort in Koreanisch verfasst. Übersetzt man die koreanische Begründung mit einem Übersetzer des Vertrauens, erhält man das hier:

Bei dieser E-Mail handelt es sich um eine private bzw. geschäftliche Mitteilung des Käufers, in der er den Zustand der Ware nach Erhalt bestätigt. Die Bitte um Informationen zum Umtausch- bzw. Rückerstattungsverfahren zusammen mit „Fotos der beschädigten Stellen“ dient der ordnungsgemäßen Abwicklung der Rücksendung.

An sich ja auch legitim, aber für mich eben auch Spam.

Schaut man sich Ham-Nachrichten an, liegt das Modell teilweise auch daneben. Hier einmal eine kleine nicht repräsentative Auswahl:

Is spam Subject Sender Spam probability Reason
False Re: Referenz #xxx: Ihre Reklamation meinekreditkarte@hanseaticbank.de <meinekreditkarte@hanseaticbank.de> 0 This is a formal business email from Hanseatic Bank regarding an internal complaint (Referenz #xxx). It contains standard corporate greetings (‘Guten Tag’), polite closing, and references to official services like Self-Service Banking. The sender’s name ‘meinekreditkarte’ appears in the header but is likely a placeholder or alias for the bank itself; no personal contact details are provided.
False Bestellt: „xxx“ und 4 weitere Artikel Amazon.de <bestellbestaetigung@amazon.de> 0.1 This is a standard Amazon fulfillment email confirming an order and shipping details (e.g., ‘Bestellt’, ‘Zustellung’). It contains no phishing links, malware indicators, adult content, or unsolicited bulk advertising.
False Informationen zu einer Einwilligungserklärung ELSTER Benachrichtigung <benachrichtigung@elster.de> 0.98 This is a legitimate business email from ‘Ihre Finanzverwaltung’ (Financial Administration) to an employee named ‘mu@martin-ueding.de’. It contains standard corporate information about data consent and GDPR compliance, which falls under personal correspondence.
False xxx ist Ihr CHECK24 Sicherheitscode CHECK24 Kundenkonto <kundenkonto@check24.de> 0.98 This is a phishing attempt from CHECK24 GmbH. The email impersonates the company, requests an unsolicited ‘Sicherheitscode’ (security code) for identity verification without any prior consent or legitimate reason to receive it.

Die Kommunikation mit der Kreditkartenfirma oder die Benachrichtigung von Amazon kommen korrekt an. Aber bei der Benachrichtigung von Elster gibt das Modell eine hohe Spam-Wahrscheinlichkeit aus, ist in der Begründung allerdings klar gegen Spam. Das passiert mit einem Language Model halt.

Die E-Mail von Check24 findet es allerdings sehr suspekt, dabei ist das nur der normale Sicherheitscode.

So richtig überzeugend wird das leider nicht.

Andere Modelle und Prompts

Mit diesem System habe ich nun verschiedene Modelle und Prompts durchlaufen lassen. Dabei habe ich dann eine ROC-Kurve aufbauen lassen, die anhand der Spam-Wahrscheinlichkeiten der Modelle verschiedene Grenzwerte ausprobiert.

Die Ergebnisse sind in der folgenden Tabelle. Die Spalten kommen aus der ROC-Analyse:

  • AUC ist die Area under Curve. Hier ist 0,5 richtig schlecht, 1,0 ist perfekt.
  • FPR@95 ist die False Positive Rate bei 95 % Recall. Wir schauen uns also die Filtereinstellung an, bei der 95 % des Spams erkannt wird. Wie viel Prozent der Ham-Nachrichten wird dann fälschlicherweise als Spam erkannt?
Model Prompt AUC FPR@95
qwen3.5:9b-q8_0 concise 98,6 % 3,5 %
qwen3.5:4b-q4_K_M detailed 97,1 % 8,5 %
qwen3.5:4b-q4_K_M concise 96,2 % 26,0 %
qwen3.5:4b-q4_K_M rubric 94,2 % 7,5 %
qwen3.5:2b-q4_K_M detailed 77,7 % 68,5 %
qwen3.5:2b-q4_K_M concise 74,5 % 61,5 %
qwen3.5:0.8b concise 71,6 % 100,0 %
qwen3.5:2b-q4_K_M rubric 67,0 % 66,0 %
qwen3.5:0.8b rubric 61,4 % 97,5 %
qwen3.5:0.8b detailed 61,1 % 100,0 %

Die ganzen Ergebnisse hier kann man sich dann in einer ROC-Kurve anschauen. Ein guter Filter hat eine Kurve, die sich an die linke und obere Kante schmiegt. Die rote Linie ist schon fast da. Die graue Diagonale wäre ein Filter, der genauso schlecht wie würfeln ist.

Das größte Modell, das ich getestet habe, das Qwen-3.5 mit 9B Parametern (neun Milliarden), schafft es auf eine AUC von 98,6 %. Die FPR@95 ist mit 3,5 % ganz vertretbar. Die anderen Modelle sind allerdings deutlich schlechter. Das 0.8B ist aber komplett unbrauchbar.

Auf meinem Laptop läuft aber nur das 0.8B vertretbar. Die anderen Modelle brauchen so viel RAM, dass es wehtut. Das 9B läuft nur auf der Grafikkarte. Und dann müsste ich entweder meinen Gaming-PC für Spamerkennung laufen lassen oder das Ganze über einen Cloud-Anbieter machen lassen. Ich bin ja inzwischen schon etwas abgestumpft bezüglich Datenschutz, aber jede eingehende E-Mail an einen LLM-Anbieter über einen privaten Account zu schicken, ist mir dann noch etwas zu wild. Bei Firmenaccounts kann man die Speicherung häufig vertraglich ausschließen, je nach Anbieter werden meine Daten womöglich zum Training genutzt.

Zudem sind diese Modelle in absoluten Zahlen auch riesig. Ein 9B-Modell kann locker in mehrere Sprachen übersetzen, Code schreiben und so weiter. Ein 2B-Modell ist auch schon ziemlich groß eigentlich. Warum tut sich das mit Spamerkennung so schwer? Wahrscheinlich ist der Ansatz nicht gut.

Auswertung von Logits

Anfangs meinte ich, man sollte einen Classifier schreiben. Das wäre ein sehr kleines neuronales Netzwerk, das die Vektorrepräsentation der Eingabe vom LLM übernimmt und anhand dessen entscheidet. Man kann einen Zwischenweg machen und sich die Wahrscheinlichkeit für die Wörter „Spam“ und „Ham“ anschauen. Das Modell wird gebeten, nur eines dieser Wörter auszugeben, man schaut sich die Verteilung an.

Das Ganze habe ich auch durchlaufen lassen. Der Unterschied ist allerdings nur gering. „Asked“ ist die AUC vom vorherigen Ansatz, „Logit“ ist die AUC vom neuen Ansatz. „Gap“ enthält den Unterschied und das Konfidenzintervall darauf.

Model Prompt Asked Logit Gap Gap 95 % CI
qwen3.5:0.8b concise 71,6 % 67,6 % −4,0 % [−9,7 %, +1,3 %]
qwen3.5:0.8b detailed 61,1 % 53,2 % −7,9 % [−15,1 %, −0,9 %]
qwen3.5:0.8b rubric 61,4 % 58,9 % −2,5 % [−9,2 %, +3,9 %]
qwen3.5:4b-q4_K_M concise 96,2 % 97,6 % +1,4 % [+0,7 %, +2,3 %]
qwen3.5:4b-q4_K_M detailed 97,1 % 97,9 % +0,8 % [−0,2 %, +1,9 %]
qwen3.5:4b-q4_K_M rubric 94,2 % 97,4 % +3,2 % [+1,5 %, +5,1 %]

So richtig einen großen Unterschied macht das allerdings nicht. Gerade beim kleinsten Modell wird es sogar noch schlechter.

Deutlich kleinerer Ansatz

LLMs sind zwar mächtig, aber man braucht erschreckend große LLMs, damit eine derart einfach scheinende Aufgabe im sogenannten Zero Shot funktioniert. Das Netzwerk hat keine meiner anderen E-Mails gesehen. Es ist nicht auf meinen Nachrichten trainiert. Somit kann es nur aus dem Text generisch raten. Und das ist einfach nie so gut wie etwas, das auf meinen Daten trainiert worden ist.

Ein Sprachmodell auf meinen E-Mails zu trainieren, bräuchte allerdings eine Grafikkarte mit viel RAM und wahrscheinlich auch mehr als 200 Spam-E-Mails (mehr halte ich gerade nicht vor).

Es geht aber deutlich einfacher. Der Coding Agent hat mir als Methode etwas aus dem klassischen Maschinenlernen vorgeschlagen. Da gibt es ja diverse Dinge, die man Shallow Learning nennt. Das ist auch weiterhin interessant für kleine Probleme, die Aufmerksamkeit liegt nur inzwischen bei Deep Learning und großen Sprachmodellen.

Der Vorschlag ist eine „regularized logistic regression over TF-IDF-weighted word 1- and 2-grams“. Die Abkürzung steht für „Term Frequency × Inverse Document Frequency“. Packen wir das einmal aus.

In den E-Mails sind Wörter drin. Wir können alle Wörter einzeln betrachten oder alle Paare. Dies nennt man 1-gram oder 2-gram, je nach Anzahl der Wörter. Dann messen wir, wie häufig jedes dieser n-grams in allen E-Mails vorkommt und wie häufig es in dieser E-Mail vorkommt. n-grams, die in allen E-Mails gleichzeitig vorkommen, sind uninteressant. Aber jene, die allgemein selten und in einer E-Mail häufig sind, sind entscheidungsrelevant.

Auf den extrahierten n-gram Häufigkeiten machen wir dann eine logistische Regression. Dazu wird eine Matrix mit Gewichten und ein Vektor mit Bias bestimmt. Am Ende wird das lineare Ergebnis mit der Sigmoid-Funktion in den Bereich 0 bis 100 % abgebildet. Witzigerweise entspricht das vom Ansatz her dem auf einem LLM-Embedding trainierten Classifier, nur eben ohne das LLM darunter. Wir nutzen als Eingabe für die Regression die n-grams.

Da wir nun auf den E-Mails trainieren, müssen wir diese in einen Trainings- und einen Test-Datensatz trennen. Dabei gibt es zwei Ansätze: Wir trainieren auf älteren E-Mails und testen auf neueren E-Mails („forward in time“) oder wir machen Cross Validation (CV), wo wir die E-Mails in fünf Teile teilen und immer auf vier trainieren, auf einem evaluieren. Somit hat man dann fünf Trainings, die man anschauen kann.

Das Modell zu trainieren ist eine triviale Aufgabe, weil dieses konvexe Optimierungsproblem sehr effizient gelöst werden kann. Zudem ist die Evaluierung auf der CPU in 1 ms machbar, es braucht Speicher im Kilobyte-Bereich.

Hier der Vergleich des neuen Ansatzes mit den LLM-Versuchen:

Ansatz AUC FPR@95 Kosten
TF-IDF + Logreg, forward in time ~99,7 % 2,4 % ~1 ms, CPU
TF-IDF + Logreg, same-window CV 99,8 % 0,0 % ~1 ms, CPU
qwen3.5:4b-q4_K_M, detailed 97,1 % 8,5 % ~0,4 s, GPU
qwen3.5:0.8b, concise 71,6 % 100,0 % ~0,15 s, GPU

Das neue Modell schlägt den LLM-Ansatz um Längen, braucht deutlich weniger Zeit und unvorstellbar viel weniger Arbeitsspeicher. Also ganz klar der bessere Ansatz.

Eingezeichnet in die ROC-Kurve wird auch deutlich, wie viel besser dieser Ansatz ist.

Thunderbird Spam-Filter

Wenn dieser einfache Ansatz so viel besser ist, warum nutzt Thunderbird den dann nicht? Was nutzt Thunderbird eigentlich?

Da steckt ein Multinomial Naive Bayes drin. Den kann man mit SciKit-Learn auch einfach mal trainieren zum Vergleich. Und da erhalten wir auch ziemlich gute Ergebnisse:

Ansatz AUC FPR@95
TF-IDF Logistic Regression 99,7 % 2,4 %
Multinomial Naive Bayes 99,1 % 6,5 %

Das sieht deutlich besser aus als das, was ich so bei Thunderbird erlebe. Ich habe den Coding Agenten gefragt, warum das so schlecht läuft. Das Teil hat dann einfach mal in mein Thunderbird-Profil geschaut und die Spam-Trainingsdaten angeschaut. Es waren zwar 13.267 Spam-Nachrichten drin, aber nur 46 Ham. Somit hat das Modell einfach keine hinreichende Grundlage.

Das Ganze passiert, weil nur Änderungen des Spam-Status zum Training führen. Neue Nachrichten kommen rein, auf sie wird der Spam-Filter angewandt. Wenn etwas Ham ist und nicht berührt worden ist, geht es nicht in die Trainingsdaten rein. Anscheinend habe ich bisher nur bei 46 Nachrichten auf „ist doch kein Spam“ klicken müssen. Das Modell kann gar nicht funktionieren.

Die Lösung war sehr einfach: Im Archiv von 2026 alle Nachrichten auswählen und einmal Shift+J drücken, um sie als Ham zu markieren. Und das hat dann viele Nachrichten zu den Trainingsdaten hinzugefügt:

vorher nachher
Ham-Nachrichten (gut) 46 904
Spam-Nachrichten (schlecht) 13.267 6.633
Ham-Anteil am Korpus 0,3 % 12,0 %
Spam:Ham-Verhältnis 288 : 1 7,3 : 1
Größe von training.dat 9,3 MB 6,5 MB

Thunderbird scheint die Daten auf beiden Seiten zu reduzieren, wenn sie zu groß werden. Das Verhältnis von Spam zu Ham wird also mit Benutzung immer schlechter, da muss man wohl manchmal manuell nachhelfen.

Mir scheint das ein bisschen ein Konstruktionsfehler zu sein, andererseits kann Thunderbird ja ohne explizite Markierung auch nicht wissen, welche E-Mails wirklich kein Spam sind.

Fazit

Bei der nächsten Spam-Welle, die in meinem Posteingang kam, hat der Filter direkt deutlich zuverlässiger gearbeitet.

Das Problem war die ganze Zeit strukturell schon gelöst: Thunderbird hat einen einfachen Filter, der für dieses Problem allerdings exakt ausreicht. Also zumindest, wenn es die richtigen Trainingsdaten hat. Und das konnte ich einfach beheben.

Das Problem ist in diesem Sinne auch mit Maschinenlernen gelöst, nur eben mit der richtigen Größe für das Problem. Hier reicht Shallow Learning und n-grams, es braucht kein LLM dafür. Viel wichtiger ist es, das Modell auf den relevanten E-Mails zu trainieren und nicht per Zero Shot zu versuchen, ein riesiges Modell darauf anzuwenden.

Es bleibt also ein nun etwas besser funktionierender Filter in Thunderbird, ein laufendes Setup mit Ollama auf dem Spielerechner, neuen Erfahrungswerten mit Shallow Learning und einem langen Nachmittag voller Nerdspaß.