Vai al contenuto

Stilometria computazionale: come misuriamo ‘voce’ su 50M articoli

Stilometria computazionale: come misuriamo ‘voce’ su 50M articoli

Stilometria computazionale: come misuriamo ‘voce’ su <a href=50M articoli” title=”Stilometria computazionale: come misuriamo ‘voce’ su 50M articoli” loading=”eager” decoding=”async” />

Novembre 2025, una sera passata a tarare il throughput del microservizio di stilometria computazionale che misura la voce autoriale dentro AI Multisite. Il numero di partenza era basso, 1.800 articoli al minuto su un singolo worker FastAPI; il target era 10.000. Sotto, una pipeline che gira su 50M articoli del corpus editoriale di Romiltec, tre macro-stadi (tokenization, feature extraction, vectorization), un classifier finale, e un sacco di trade-off su precision-recall che dipendono dal contesto editoriale. Vedi anche gli altri post di questa rubrica: Production.

Perché misurare la voce autoriale

Domanda che mi ha fatto un caporedattore di un cliente storico l’estate scorsa: “Possiamo capire se questo articolo, pubblicato a nome dell’autore X, ha effettivamente la sua voce o se è stato scritto da qualcun altro nella redazione che ha firmato col suo nome?”. Caso reale: in alcuni contesti editoriali grandi, la pipeline fra autore-firma-pubblicazione può avere passaggi non sempre tracciati; questo si è acuito con l’arrivo dell’AI generativa.

Lo stesso sistema serve a tre scopi nel nostro prodotto:

  1. Quality gate sull’output AI: se il modello LLM genera un pezzo “a nome di X”, la stilometria misura la distanza dallo stile reale di X e ferma il pezzo se la distanza supera la soglia.
  2. Audit retrospettivo sui contenuti pubblicati: dato un autore e i suoi 5.000 articoli, identifica gli outlier (articoli pubblicati a suo nome che statisticamente non sono suoi).
  3. Personalizzazione del prompt LLM per la generazione assistita: estraiamo il “vettore stile” dell’autore e lo usiamo come istruzione quantitativa al modello, dentro l’orchestrazione prompt a quattro livelli.

Il punto è che lo stile autoriale non è una cosa qualitativa che si può solo “leggere”. È misurabile, con un margine di errore che si quantifica.

La pipeline: i tre stadi

Stadio 1: tokenization

Il primo passo è banale solo in apparenza. Per una pipeline che gira su corpora editoriali italiani (e in seconda fase su rumeno, polacco, inglese), il tokenizer fa la differenza fra un classifier che funziona e uno che non funziona.

Scelta: spaCy con il modello it_core_news_lg per l’italiano. Per il rumeno usiamo ro_core_news_md, per il polacco pl_core_news_md. Inglese en_core_web_lg. Lemma e POS tagging sono nativi. Performance: circa 8.000 token al secondo per worker su una macchina con CPU 8 core.

Alternativa che ho considerato: tokenizer custom con regex. Più veloce in throughput puro, ma perdeva troppe informazioni morfologiche. Per la stilometria la POS distribution è una feature fondamentale, e calcolarla bene richiede un modello vero.

Estrazione per articolo:
– lista dei token
– lista dei lemma
– lista delle POS tag
– numero di sentence
– numero di parole
– numero di caratteri (escludendo whitespace)

L’output dello stadio 1 è un JSON serializzato che alimenta lo stadio 2.

Stadio 2: feature extraction

Le features stilometriche classiche le abbiamo divise in cinque famiglie. Documentazione di riferimento: il survey di Stamatatos del 2009 sui metodi di authorship attribution, ancora il punto di partenza standard.

Function word frequency. Le parole funzionali (articoli, preposizioni, congiunzioni, pronomi) sono quelle che più caratterizzano lo stile e meno il contenuto. Calcoliamo la frequenza di ognuna delle 250 function word italiane più comuni. Vettore di 250 dimensioni per articolo.

POS distribution. Frequenza relativa delle 17 POS tag spaCy (NOUN, VERB, ADJ, ADV, PRON, …). Vettore di 17 dimensioni.

Sentence statistics. Lunghezza media delle frasi in parole, deviazione standard, lunghezza mediana, percentile 90 (per catturare la presenza di frasi-fiume). 4 dimensioni.

Lexical diversity. Type-Token Ratio standard, MTLD (Measure of Textual Lexical Diversity, più stabile su testi lunghi), HD-D (Hypergeometric Distribution Diversity). 3 dimensioni.

Punctuation pattern. Frequenza di virgola, punto, punto e virgola, due punti, parentesi, lineetta, virgolette. 7 dimensioni.

Vettore stilometrico per articolo: 281 dimensioni totali. È un vettore “tradizionale”, interpretabile, che possiamo mostrare al caporedattore con etichette (“questo autore usa il punto e virgola tre volte di più della media”).

Stadio 3: vectorization semantica

In aggiunta al vettore tradizionale, calcoliamo un embedding semantico via sentence-transformer. Modello: paraphrase-multilingual-MiniLM-L12-v2 di Sentence Transformers, 384 dimensioni, multilingue.

L’embedding semantico cattura cosa l’autore tende a scrivere (tematiche, registri, contesti), il vettore stilometrico cattura come l’autore scrive. Concatenati, formano un vettore di 281+384 = 665 dimensioni.

Performance: circa 2.000 articoli al minuto per worker per la parte sentence-transformer (è la parte più lenta della pipeline, GPU-friendly ma noi giriamo su CPU per ora, con quantizzazione int8 attivata via optimum-onnx).

Il classifier

Due modi di usare il vettore di 665 dimensioni:

Binario per autore. Dato un autore X e un articolo, il classifier risponde sì/no. Implementazione: un modello logistic regression L2 per autore, addestrato su tutti gli articoli storici dell’autore (positivi) più un campione bilanciato di articoli di altri autori della stessa redazione (negativi). Iperparametri ottimizzati via grid search semplice. Per autori con almeno 200 articoli storici, F1 medio 0,87, precision 0,89, recall 0,85.

Multi-classe sulla redazione. Dato un articolo, restituisce la probabilità che sia di ognuno degli N autori conosciuti della redazione. Implementazione: SVM lineare con decision_function calibrato via Platt scaling. Per redazioni piccole (10-30 autori) accuracy top-1 sopra 0,80, top-3 sopra 0,95.

Edge case importante: il contesto editoriale. Un autore può variare deliberatamente il tono per rubrica. Lo stesso autore che firma articoli di cronaca con frasi corte e prosa asciutta scrive editoriali con frasi lunghe e lessico più alto. Se non controlliamo per la rubrica, il classifier flaggherebbe come “non suo” l’editoriale corretto.

Soluzione: il vettore stilometrico viene normalizzato sotto-rubrica. Per ogni autore, calcoliamo un vettore stile per ogni rubrica in cui ha pubblicato almeno 50 articoli. La query “questo articolo, in rubrica Y, è di X?” usa il vettore di X-rubrica-Y. Se l’autore non ha abbastanza articoli in quella rubrica, fallback al vettore globale con un confidence score abbassato.

Throughput: dai 1.800 ai 10.000 articoli al minuto

Il microservizio gira come FastAPI. L’architettura iniziale era:

HTTP request → endpoint /score
            → tokenizer spaCy (sync)
            → feature extraction (sync)
            → sentence-transformer (sync)
            → classifier (sync)
            → response JSON

Misurazione di partenza: 1.800 articoli al minuto su un worker, p99 latency 380ms. Bottleneck identificato con un profiler (cProfile) su un campione di 10.000 articoli: 60% del tempo nel sentence-transformer, 25% nello spaCy, 15% in feature extraction e classifier.

Ottimizzazioni applicate, in ordine:

  1. Batching della sentence-transformer. Invece di chiamare model.encode([text]) per articolo, accumulare in coda Redis e chiamare model.encode(batch_of_64) quando il batch è pieno o quando un timeout di 100ms scade. Throughput sentence-transformer da 2.000 a 8.000 articoli/min per worker.

  2. Tokenizer pool. Lo nlp di spaCy non è thread-safe in Python (GIL e modello stateful). Risolto con un pool di processi worker, ognuno con il proprio tokenizer caricato, comunicazione via queue. Da 8.000 a 15.000 token/sec aggregati.

  3. Caching feature lessicali per autore. Le feature di funzione (function word baseline, distribuzione POS) per ogni autore le calcoliamo una volta a settimana su un job offline e le memorizziamo in Redis. La query a runtime fa solo extraction sull’articolo nuovo, niente recompute sull’intero corpus dell’autore.

  4. Quantizzazione del sentence-transformer. Da float32 a int8 via optimum-onnx. Throughput +35% senza perdita misurabile di accuracy sul nostro test set.

  5. Worker queue Redis con FastAPI in modalità async per l’endpoint pubblico, calcolo pesante in worker separati Celery. L’API ritorna un task_id immediato, polling o webhook per il risultato.

Throughput finale misurato in produzione: 10.400 articoli al minuto aggregati su quattro worker, p99 latency end-to-end (richiesta -> risultato disponibile) 850ms. Soddisfacente per il caso d’uso (audit batch, generazione assistita).

Performance di precision e recall

L’output del classifier non è un’etichetta nera/bianca, è una probabilità con un confidence score. La soglia di decisione si tara per il caso d’uso:

Quality gate AI (alta precision richiesta, vogliamo evitare di pubblicare pezzi che non suonano dell’autore): soglia 0,85, precision 0,94, recall 0,72. Il 28% di pezzi rifiutati come “non suoi” include falsi positivi che richiedono review umana.

Audit retrospettivo (alto recall richiesto, vogliamo flaggare tutti i sospetti per revisione): soglia 0,55, precision 0,79, recall 0,93. Il 7% di articoli sospetti che non sono effettivamente outlier viene chiuso dal review umano.

Personalizzazione prompt LLM (la stilometria è un suggerimento, non un gate): nessuna soglia, il vettore alimenta direttamente il prompt come istruzione quantitativa.

Trade-off classico precision-recall, niente magia. Il valore del sistema è che la soglia è esplicita, parametrica, monitorata. Non è un “modello AI nero” che decide opacamente: è un classifier interpretabile con una decision function visibile.

Misurazione e monitoraggio

In produzione il microservizio espone metriche Prometheus su:

  • throughput (articoli/min)
  • latency p50/p95/p99
  • queue depth Redis
  • F1 score sul test set golden (ricalcolato settimanalmente su un campione manuale)
  • cache hit rate sulle feature di autore

Grafana dashboard con alert: se il throughput scende sotto 6.000/min per 10 minuti, alert su Mattermost; se F1 scende sotto 0,80 sul test set, alert e blocco del deploy successivo.

Deploy e versionamento del modello

Il classifier per autore è un modello che cambia nel tempo (l’autore evolve, la redazione gli affida nuove rubriche). Riaddestramento settimanale, automatizzato. Versionamento dei modelli in S3-compatible (Cloudflare R2), nominati con sha256 del training dataset più timestamp.

Roll-out di un nuovo modello: shadow mode per 48 ore (il nuovo modello calcola in parallelo al vecchio, salva i risultati ma non li serve), confronto su un campione di 5.000 articoli giornalieri, switch atomico se la divergenza è sotto la soglia.

Quello che ho imparato

Prima. La stilometria è feature engineering, non deep learning. Il sistema che funziona è quello che combina un vettore tradizionale interpretabile (281 dimensioni) con un embedding semantico (384 dimensioni). Pretendere di risolvere tutto con un solo modello end-to-end è la strada per ottenere F1 più bassi e zero spiegabilità verso il caporedattore. Il caporedattore vuole vedere “questo autore usa il punto e virgola tre volte la media”, non “il modello dice 0,73”.

Seconda. Il throughput si guadagna a stadi, non con la magia. Da 1.800 a 10.000 articoli/min ho fatto cinque ottimizzazioni separate, ognuna giustificata dal profiler. Provare a riscrivere il microservizio in Rust o Go all’inizio sarebbe stato l’errore classico: il bottleneck era nel modello sentence-transformer, non in Python. Sostituire FastAPI sarebbe servito a niente, batching e quantizzazione hanno fatto tutto.

Terza. Il contesto editoriale è una variabile esplicita, non un disturbo da ignorare. Lo stesso autore scrive diverso per rubrica, per lunghezza richiesta, per evento d’attualità. Il modello che funziona sui 50M articoli è quello che modella esplicitamente il contesto (rubrica, lunghezza target, periodo) e normalizza il vettore di stile dentro quel contesto. Ignorarlo significa accettare un 5-7% di accuracy persa per pulizia teorica del modello.

Cinquanta milioni di articoli sono un numero che impressiona, ma il pezzo interessante non è il volume. È che la pipeline gira ogni giorno su quel volume, restituisce metriche che il caporedattore può leggere, e si aggiorna senza intervento umano fino al test golden settimanale. La parte tecnica più sottile è la più invisibile: l’osservabilità sul comportamento del modello in produzione. Senza quella, il modello è una scatola chiusa, e una scatola chiusa in produzione è un debito che ti torna addosso al primo cambio di pattern editoriale.