Després de les imatges del projecte 2, una altra dada no tabular: el text. En aquest projecte construiràs de cap a cap un classificador de sentiment —positiu o negatiu— per a opinions a les xarxes socials sobre un servei de lliurament a domicili, amb el NLP clàssic: neteja de text, representació bag-of-words i TF-IDF, i els models del mòdul 4, amb el Naive Bayes multinomial de 04-06 com a protagonista (allà te'l vam anticipar "per a text"; avui compleix la seva promesa). Perquè el projecte sigui autocontingut, el dataset és fictici però realista: unes 40 opinions en català sobre "RepartoYa", un servei de lliurament de l'estil de MercaFresh, construïdes al mateix codi. En un cas real serien milers de files d'un CSV públic — al final del projecte et diem on aconseguir-les.

Contingut

  1. Definició del problema i mètrica objectiu
  2. Construcció del dataset
  3. Exploració del text
  4. Neteja i normalització
  5. Representació: de paraules a números
  6. Modelatge: Naive Bayes, logística i SVM lineal
  7. Avaluació amb validació creuada estratificada
  8. Interpretació: quines paraules pesen
  9. Límits de l'enfocament clàssic
  10. Conclusions

Definició del problema i mètrica objectiu

  • Problema: classificació binària de textos curts — l'opinió és positiva (1) o negativa (0)?
  • Ús de negoci: prioritzar l'atenció al client (respondre abans als enfadats) i mesurar la salut de la marca a les xarxes.
  • Mètrica: amb classes equilibrades com aquí, accuracy més la matriu de confusió; en un desplegament real interessaria especialment el recall de la classe negativa (que no se'ns escapi cap client furiós), el mateix raonament per costos de 06-04 "Corba ROC i AUC".
  • Criteri d'èxit: batre l'atzar (50 %) amb claredat i, sobretot, que el model sigui interpretable: hem de poder veure quines paraules el convencen.

Construcció del dataset

Amb 40 exemples no s'entrena un producte: s'aprèn un flux. Els construïm equilibrats i amb els vicis reals del text a les xarxes (majúscules, URLs, mencions, emojis, excés de signes):

import pandas as pd

positius = [
    "M'ha arribat la comanda en 20 minuts, servei increïble!!",
    "La fruita fresquíssima i el repartidor amabilíssim 😍",
    "@RepartoYa sou els millors, tot perfecte com sempre",
    "Molt contenta amb el lliurament, puntual i tot ben embalat",
    "Segur que repetiré, bons preus i lliurament rapidíssim",
    "El peix congelat va arribar impecable, gran feina",
    "Atenció al client de 10, em van resoldre el problema al moment",
    "Quina meravella poder fer la compra sense sortir de casa 👏",
    "Comanda completa i sense errors, així dona gust",
    "L'app és comodíssima i el repartiment molt ràpid",
    "Fantàstic com sempre, ni una queixa en sis mesos",
    "El repartidor va pujar les bosses fins al cinquè, un crac",
    "Tot fresc, tot puntual, recomanadíssim https://repartoya.example",
    "Em van avisar del retard i em van regalar l'enviament, ben gestionat",
    "Els substituts que van triar eren millors que la meva comanda original",
    "Lliurament en franja d'una hora i clavat, perfecte",
    "Qualitat immillorable de la carn, tornaré a demanar",
    "Molt bon servei, el web funciona de meravella",
    "Cinc estrelles, ràpid i sense res trencat",
    "El millor súper online que he provat, de debò",
]

negatius = [
    "Dues hores de retard i ningú no contesta al telèfon 😡",
    "@RepartoYa m'heu portat els iogurts CADUCATS, vergonyós",
    "Comanda incompleta una altra vegada, hi faltaven 5 productes",
    "La llet va arribar calenta i el gelat fet sopa, fatal",
    "Impossible contactar amb atenció al client, servei pèssim",
    "Em van cobrar dues vegades i encara espero la devolució",
    "Les bosses trencades i els ous aixafats, un desastre",
    "Em van anul·lar la comanda sense avisar, no hi torno ni boja",
    "El repartidor va deixar la compra en un altre portal, increïble però malament",
    "Fruita passada i pa dur, quina decepció https://queja.example",
    "45 minuts al telèfon per a res, experiència horrible",
    "Tercera vegada que arriba tard, no n'aprenen",
    "L'app es penja en pagar, impossible acabar la compra",
    "Em van substituir el salmó per barretes de cranc, de debò??",
    "Tot caríssim i a sobre l'enviament tard, no val la pena",
    "Vaig demanar en oferta i em van cobrar preu normal, enganyosos",
    "Ningú no respon a les xarxes, atenció pèssima",
    "La comanda va arribar congelada quan era fresca, molt malament",
    "Una hora esperant al portal perquè no trobaven el carrer",
    "Decebedor de principi a fi, no recomano gens el servei 👎",
]

df = pd.DataFrame({
    "text": positius + negatius,
    "sentiment": [1] * len(positius) + [0] * len(negatius),
})
print(df.shape, "| balanc:", df["sentiment"].mean())

Transparència metodològica: en un projecte real aquest pas seria un pd.read_csv(...) sobre milers d'opinions etiquetades (a mà o per regles com les estrelles d'una ressenya). Tot el que segueix funciona idènticament amb 40 files o amb 40.000 — només canvia la solidesa estadística dels resultats, com et va ensenyar 02-04 "Inferència estadística": amb mostres petites, intervals amples i humilitat.

Exploració del text

L'EDA de text comença comptant coses:

df["n_paraules"] = df["text"].str.split().str.len()
print(df.groupby("sentiment")["n_paraules"].describe().round(1))

from collections import Counter
paraules_neg = Counter(" ".join(df[df.sentiment == 0]["text"]).lower().split())
print(paraules_neg.most_common(10))

Ja s'hi veuen pistes: als negatius hi abunden "tard", "pèssim", "ningú", "una altra vegada"; als positius, "ràpid", "perfecte", "fresc". El classificador que construirem formalitzarà exactament aquesta intuïció.

Neteja i normalització

El text en brut de les xarxes porta soroll que no aporta senyal de sentiment (URLs, mencions) i variants superficials de la mateixa cosa ("Increïble" vs. "increïble"). La neteja és l'equivalent textual de 03-01 "Neteja de dades":

import re

def netejar(text):
    t = text.lower()                               # 1. minuscules
    t = re.sub(r"https?://\S+", " ", t)            # 2. URLs fora
    t = re.sub(r"@\w+", " ", t)                    # 3. mencions fora
    t = re.sub(r"[^\wàèéíïòóúüç\s]", " ", t)       # 4. signes i emojis fora
    t = re.sub(r"\s+", " ", t).strip()             # 5. espais multiples
    return t

df["text_net"] = df["text"].map(netejar)
print(df[["text", "text_net"]].head(3).to_string())

Cada pas és una decisió amb cost: en eliminar els emojis perdem 😡 i 😍, que són senyal puríssim de sentiment (una millora òbvia seria convertir-los en tokens com emoji_enuig abans de netejar). Fixa't també que el pas 4 escombra els apòstrofs: "l'app" queda com a "l app" (i el tokenitzador descartarà la lletra solta). La tokenització — trossejar el text en paraules — la farà el vectoritzador per nosaltres separant per no-alfanumèrics; per a aquest enfocament simple ja és suficient.

Representació: de paraules a números

Cap model del mòdul 4 no menja text: mengen matrius numèriques. La solució clàssica és bag-of-words ("bossa de paraules"): cada paraula diferent del vocabulari és una columna, i cada text una fila amb el nombre de vegades que fa servir cada paraula. Es perd l'ordre ("no m'agrada" i "m'agrada no" són iguals) però es guanya una taula sobre la qual tot el que has après funciona.

CountVectorizer construeix aquesta matriu; TfidfVectorizer la millora ponderant: TF-IDF multiplica la freqüència de la paraula en el document (TF) per un factor que penalitza les paraules que apareixen en gairebé tots els documents (IDF). Així "comanda", que és pertot arreu, pesa poc, i "caducats", rara però reveladora, pesa molt.

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

# sklearn no porta stopwords en catala: definim les nostres
stopwords_ca = [
    "de", "la", "el", "en", "i", "a", "que", "els", "les", "un", "una",
    "em", "al", "del", "per", "amb", "es", "ho", "li", "va", "van", "ha",
]

vectoritzador = TfidfVectorizer(
    stop_words=stopwords_ca,
    ngram_range=(1, 2),   # unigrames i bigrames
    min_df=1,
)
X_demo = vectoritzador.fit_transform(df["text_net"])
print(X_demo.shape)  # (40, n_termes): matriu dispersa

Dos paràmetres mereixen explicació:

  • ngram_range=(1, 2): a més de paraules soltes, fa servir parells consecutius (bigrames). És la manera barata de recuperar una mica d'ordre: "no recomano" com a terme propi distingeix allò que "no" i "recomano" per separat confonen.
  • stop_words: paraules buides ("de", "la", "que") que només aporten soroll. Compte: amb sentiment cal esporgar amb cura — "no" no ha de ser mai stopword aquí.

La matriu resultant és dispersa (gairebé tot zeros): cada text fa servir unes poques desenes d'un vocabulari de centenars. sklearn la gestiona eficientment sense convertir-la a densa.

Modelatge: Naive Bayes, logística i SVM lineal

Tres clàssics idonis per a matrius disperses d'alta dimensió, cadascun dins el seu Pipeline (vectoritzador inclòs, perquè el vocabulari s'aprengui només del train de cada partició — la disciplina antifuites de 06-01):

from sklearn.pipeline import Pipeline
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC

def pipe(model):
    return Pipeline([
        ("vec", TfidfVectorizer(stop_words=stopwords_ca, ngram_range=(1, 2))),
        ("clf", model),
    ])

models = {
    "MultinomialNB": pipe(MultinomialNB()),
    "Logistica": pipe(LogisticRegression(max_iter=1000)),
    "SVM lineal": pipe(LinearSVC()),
}

MultinomialNB és el retrobament promès a 04-06 "Naive Bayes": modela la probabilitat de cada paraula donada la classe (quina és la probabilitat de veure "caducats" en una opinió negativa?) i les combina totes amb el teorema de Bayes de 02-05, assumint independència entre paraules — una suposició falsa que en text funciona sorprenentment bé. La logística de 04-02 i la SVM lineal de 04-04 aprenen, en canvi, un pes per terme, cosa que les fa molt interpretables.

Avaluació amb validació creuada estratificada

Amb 40 exemples, una única divisió train/test seria una loteria. Fem servir la CV estratificada de 06-03 "Validació creuada", que a més manté el 50/50 de classes a cada partició:

from sklearn.model_selection import StratifiedKFold, cross_val_score, cross_val_predict
from sklearn.metrics import confusion_matrix, classification_report

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for nom, model in models.items():
    acc = cross_val_score(model, df["text_net"], df["sentiment"], cv=cv)
    print(f"{nom}: {acc.mean():.2f} +- {acc.std():.2f}")

# Matriu de confusio agregada del millor
y_pred = cross_val_predict(models["MultinomialNB"],
                           df["text_net"], df["sentiment"], cv=cv)
print(confusion_matrix(df["sentiment"], y_pred))
print(classification_report(df["sentiment"], y_pred,
                            target_names=["negatiu", "positiu"]))
Model Accuracy (CV 5) Comentari
MultinomialNB ≈ 0,85–0,95 Excel·lent amb poca dada: el seu biaix fort el protegeix
Logística ≈ 0,80–0,90 Molt interpretable; demanarà més dades per brillar
SVM lineal ≈ 0,80–0,90 Similar; sol guanyar quan hi ha milers d'exemples

Amb 8 exemples per partició de test, cada encert mou la mètrica un 12,5 %: les desviacions són enormes i cap rànquing entre aquests tres no és concloent. La conclusió honesta és la contrària: tots tres baten amb claredat l'atzar, senyal que el senyal lèxic del sentiment és fort. Amb milers d'exemples, les diferències sí que serien mesurables.

Interpretació: quines paraules pesen

La gran virtut de l'enfocament clàssic: es pot auditar. Entrenem la logística amb tot i en mirem els coeficients per terme (positiu = empeny cap a sentiment positiu):

model = models["Logistica"].fit(df["text_net"], df["sentiment"])
vec = model.named_steps["vec"]
pesos = pd.Series(model.named_steps["clf"].coef_[0],
                  index=vec.get_feature_names_out())

print("Termes mes POSITIUS:\n", pesos.nlargest(10).round(2))
print("\nTermes mes NEGATIUS:\n", pesos.nsmallest(10).round(2))

Hauries de veure "ràpid", "perfecte", "fresc" a dalt i "tard", "pèssim", "ningú", "no" a baix. Això serveix per a tres coses: validar que el model aprèn sentiment i no casualitats ("si 'cinquè' sortís com a terme negatiu, sospitaria"), explicar prediccions individuals a negoci, i detectar biaixos — si un nom propi o un col·lectiu hi aparegués amb un pes fort, tindries al davant el problema dels proxies de 08-04 "Consideracions ètiques i de privadesa".

Límits de l'enfocament clàssic

Honestedat obligada: bag-of-words no entén el llenguatge, compta paraules.

  • Negació i context: "no està gens malament" és positiu; per a la bossa de paraules és "no" + "gens" + "malament" = negativíssim. Els bigrames ho mitiguen, no ho curen.
  • Ironia i sarcasme: "genial, una altra vegada tard 👏" derrota qualsevol comptador de paraules.
  • Vocabulari tancat: una paraula no vista durant l'entrenament no aporta res, encara que sigui "nefast".

Els transformers que es van esmentar a 07-04 "Xarxes neuronals profundes (Deep Learning)" i entre els frameworks de 08-01 (Hugging Face) resolen gran part d'això: llegeixen la frase sencera, entenen el context i arriben preentrenats amb l'idioma ja après. A canvi: més cost de còmput, menys interpretabilitat directa i més infraestructura. En la pràctica professional, un TF-IDF + lineal continua sent un baseline excel·lent i sovint suficient — i sempre és el primer esglaó contra el qual mesurar qualsevol transformer.

Errors Comuns i Consells

  • Vectoritzar abans de dividir. Ajustar el TfidfVectorizer amb tots els textos filtra vocabulari (i freqüències IDF!) del test cap al train. Dins del Pipeline, impossible equivocar-se.
  • Eliminar "no" com a stopword. Les llistes genèriques de stopwords inclouen la negació; en sentiment és la paraula més informativa. Revisa sempre la llista per al teu problema.
  • Netejar de més. Els emojis, les majúscules sostingudes ("CADUCATS") i els signes repetits ("!!!") porten senyal emocional. Abans d'esborrar-los, pregunta't si podrien ser features.
  • Creure's una accuracy amb 40 exemples. Reporta sempre la desviació entre particions i, amb mostres petites, conclusions qualitatives ("bat l'atzar") millor que rànquings de dècimes.
  • Etiquetes mandroses. Si etiquetes ressenyes per estrelles (1–2 = negatiu, 4–5 = positiu), documenta què fas amb les de 3: incloure-les mal etiquetades embruta més que descartar-les.

Reptes per ampliar

  1. Escala a un dataset real. Substitueix les 40 frases per milers: els datasets de TASS (tallers d'anàlisi de sentiment en espanyol de la SEPLN), el corpus de ressenyes Multilingual Amazon Reviews, o ressenyes públiques de pel·lícules disponibles a Hugging Face Datasets. Pista: amb milers d'exemples, repeteix la comparació dels tres models i comprova si la SVM avança el Naive Bayes; afegeix min_df=5 per esporgar rareses.
  2. Lematització amb spaCy. Instal·la spacy i el model ca_core_news_sm, i substitueix cada paraula pel seu lema ("arribaven" → "arribar", "caducats" → "caducat") abans de vectoritzar. Pista: redueix el vocabulari agrupant variants; mesura si millora la CV o només ho fa més lent — no sempre compensa.
  3. Un transformer en tres línies. Prova el pipeline("sentiment-analysis", model=...) de Hugging Face amb un model multilingüe o en català (cerca "sentiment" al seu hub) sobre les teves 40 frases i compara'n els encerts amb els del teu TF-IDF, mirant especialment les frases amb negació o sarcasme. Pista: no necessita entrenament — arriba preentrenat; fixa't també en quant triga per frase en comparació amb el teu model lineal.

Conclusió

Tercer projecte complet: has convertit text lliure en una matriu que els models del mòdul 4 saben pair — neteja amb expressions regulars, bossa de paraules, TF-IDF amb n-grames —, has retrobat el MultinomialNB en el terreny per al qual te'l vam anunciar a 04-06, has avaluat amb la CV estratificada que la mostra petita exigia i has auditat el model paraula a paraula, que és el superpoder de l'enfocament clàssic davant de caixes més negres. I has après a declarar límits: la negació i el sarcasme marquen la frontera on els transformers agafen el relleu, amb el seu cost. Fixa't en el patró que es repeteix en aquests tres projectes: la representació de les dades importa tant com l'algorisme. En el pròxim projecte el repte no serà a la representació sinó a la distribució: detectar fraus quan el 99 % de les transaccions són legítimes i l'accuracy es converteix en una mentidera professional.

Curs de Machine Learning

Mòdul 1: Introducció al Machine Learning

Mòdul 2: Fonaments d'Estadística i Probabilitat

Mòdul 3: Preprocessament de Dades

Mòdul 4: Algorismes de Machine Learning Supervisat

Mòdul 5: Algorismes de Machine Learning No Supervisat

Mòdul 6: Avaluació i Validació de Models

Mòdul 7: Tècniques Avançades i Optimització

Mòdul 8: Implementació i Desplegament de Models

Mòdul 9: Projectes Pràctics

Mòdul 10: Recursos Addicionals

© Copyright 2026. Tots els drets reservats