NLP (doc2vec da zero) e Clustering Classificazione dei report di notizie in base al contenuto del testo

NLP (doc2vec da zero) e Clustering Classificazione dei report di notizie basata sul contenuto del testo

Utilizzare NLP (doc2vec), con pulizia del testo profonda e personalizzata, e quindi clustering (Birch) per individuare argomenti nel testo degli articoli di notizie.

In questo esempio utilizzo NLP (Doc2Vec) e algoritmi di clustering per cercare di classificare le notizie per argomento.

Ci sono molti modi per effettuare questo tipo di classificazione, come l’utilizzo di metodi supervisionati (un set di dati contrassegnati), l’utilizzo del clustering e l’utilizzo di un algoritmo specifico LDA (modello di argomento).

Utilizzo il Doc2Vec perché lo considero un buon algoritmo per la vettorizzazione del testo ed è relativamente semplice da addestrare da zero.

La panoramica generale di come affronterò questa situazione è la seguente:

Come al solito, il primo passo è caricare le librerie richieste:

#per elaborare i datiimport numpy as npimport pandas as pd#sorgente dati dizionario è in formato jsonimport jsonpd.options.mode.chained_assignment = None  #leggi da file da discofrom io import StringIO#pre-elaborazione e pulizia del testoimport reimport nltkfrom nltk.corpus import stopwordsnltk.download('stopwords')nltkstop = stopwords.words('italian')from nltk.stem.snowball import SnowballStemmernltk.download('punkt')snow = SnowballStemmer(language='italiano')#modellazionedalla gensim.models.doc2vec import Doc2Vec, TaggedDocumentfrom nltk.tokenize import word_tokenizefrom sklearn.decomposition import PCAfrom sklearn.preprocessing import StandardScalerfrom sklearn.metrics import pairwise_distancesfrom sklearn.cluster import Birchfrom sklearn.metrics import silhouette_samples, silhouette_score, calinski_harabasz_scoreimport warnings#graficiimport matplotlib.pyplot as pltimport matplotlib.cm as cmimport seaborn as sns

Quindi, leggo i dati e preparo i file di dizionario. Questi provengono originariamente da set di dati pubblici su Kaggle (elenco di paesi, nomi, valute, ecc.)

#questo è il dataset degli articoli da elaboraremaindataset = pd.read_csv("articles1.csv")maindataset2 = pd.read_csv("articles2.csv")maindataset = pd.concat([maindataset,maindataset2], ignore_index=True)#questa è una lista di paesi. Sostituiremo i nomi dei paesi negli articoli con xpaesixcountries = pd.read_json("countries.json")countries["country"] = countries["country"].str.lower()countries = pd.DataFrame(countries["country"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())countries.columns = ['word']countries["replacement"] = "xpaesix"#questa è una lista di provincie. Questa lista include diversi nomi alternativi e un elenco di paesi, che sto aggiungendo anche al dizionarioprovincies = pd.read_csv("countries_provincies.csv")provincies1 = provincies[["name"]]provincies1["name"] = provincies1["name"].str.lower()provincies1 = pd.DataFrame(provincies1["name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies1.columns = ['word']provincies1["replacement"] = "xprovincex"provincies2 = provincies[["name_alt"]]provincies2["name_alt"] = provincies2["name_alt"].str.lower()provincies2 = pd.DataFrame(provincies2["name_alt"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies2.columns = ['word']provincies2["replacement"] = "xprovincex"provincies3 = provincies[["type_en"]]provincies3["type_en"] = provincies3["type_en"].str.lower()provincies3 = pd.DataFrame(provincies3["type_en"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies3.columns = ['word']provincies3["replacement"] = "xsuddivisionex"provincies4 = provincies[["admin"]]provincies4["admin"] = provincies4["admin"].str.lower()provincies4 = pd.DataFrame(provincies4["admin"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies4.columns = ['word']provincies4["replacement"] = "xpaesix"provincies5 = provincies[["geonunit"]]provincies5["geonunit"] = provincies5["geonunit"].str.lower()provincies5 = pd.DataFrame(provincies5["geonunit"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies5.columns = ['word']provincies5["replacement"] = "xpaesix"provincies6 = provincies[["gn_name"]]provincies6["gn_name"] = provincies6["gn_name"].str.lower()provincies6 = pd.DataFrame(provincies6["gn_name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies6.columns = ['word']provincies6["replacement"] = "xpaesix"provincies = pd.concat([provincies1,provincies2,provincies3,provincies4,provincies5,provincies6], axis=0, ignore_index=True)#elenco delle valutecurrencies = pd.read_json("country-by-currency-name.json")currencies1 = currencies[["country"]]currencies1["country"] = currencies1["country"].str.lower()currencies1 = pd.DataFrame(currencies1["country"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())currencies1.columns = ['word']currencies1["replacement"] = "xpaesix"currencies2 = currencies[["currency_name"]]currencies2["currency_name"] = currencies2["currency_name"].str.lower()currencies2 = pd.DataFrame(currencies2["currency_name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())currencies2.columns = ['word']currencies2["replacement"] = "xvalutax"currencies = pd.concat([currencies1,currencies2], axis=0, ignore_index=True)#nomi propri di personafirstnames = pd.read_csv("interall.csv", header=None)firstnames = firstnames[firstnames[1]>=10000]firstnames = firstnames[[0]]firstnames[0] = firstnames[0].str.lower()firstnames = pd.DataFrame(firstnames[0].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())firstnames.columns = ['word']firstnames["replacement"] = "xnomesx"#cognomicognomi = pd.read_csv("intersurnames.csv", header=None)cognomi = cognomi[cognomi[1]>=10000]cognomi = cognomi[[0]]cognomi[0] = cognomi[0].str.lower()cognomi = pd.DataFrame(cognomi[0].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())cognomi.columns = ['word']cognomi["replacement"] = "xcognomex"#nomi di mesi, giorni e altre denominazioni temporali.temporaldata = pd.read_csv("temporal.csv")#dizionario completodictionary = pd.concat([cognomi,temporaldata,firstnames,currencies,provincies,countries], axis=0, ignore_index=True)dictionary = dictionary.groupby(["word"]).first().reset_index(drop=False)dictionary = dictionary.dropna()maindataset

Questa è un’anteprima del dataset originale

maindataset

Le funzioni successive hanno il compito di:

  1. Sostituire le parole utilizzando il dizionario creato in precedenza
  2. Rimuovere la punteggiatura, gli spazi doppi, etc.
def sostituisci_parole(tt, dizionario): 
    temp = tt.split() 
    res = [] 
    for parola in temp:  
        res.append(dizionario.get(parola, parola)) 
    res = ' '.join(res) 
    return res

def preprepara(testo): 
    testo = testo.lower() 
    testo = testo.replace(u'\xa0', u' ') 
    testo = re.sub(r'^\s*$',' ', str(testo))
    testo = testo.replace('|', ' ')
    testo = testo.replace('ï', ' ')
    testo = testo.replace('»', ' ')
    testo = testo.replace('¿', '. ')
    testo = testo.replace('', ' ')
    testo = testo.replace('"', ' ')
    testo = testo.replace("'", " ")
    testo = testo.replace('?', ' ')
    testo = testo.replace('!', ' ')
    testo = testo.replace(',', ' ')
    testo = testo.replace(';', ' ')
    testo = testo.replace('.', ' ')
    testo = testo.replace("(", " ")
    testo = testo.replace(")", " ")
    testo = testo.replace("{", " ")
    testo = testo.replace("}", " ")
    testo = testo.replace("[", " ")
    testo = testo.replace("]", " ")
    testo = testo.replace("~", " ")
    testo = testo.replace("@", " ")
    testo = testo.replace("#", " ")
    testo = testo.replace("$", " ")
    testo = testo.replace("%", " ")
    testo = testo.replace("^", " ")
    testo = testo.replace("&", " ")
    testo = testo.replace("*", " ")
    testo = testo.replace("<", " ")
    testo = testo.replace(">", " ")
    testo = testo.replace("/", " ")
    testo = testo.replace("\\", " ")
    testo = testo.replace("`", " ")
    testo = testo.replace("+", " ")
    testo = testo.replace("=", " ")
    testo = testo.replace("_", " ")
    testo = testo.replace("-", " ")
    testo = testo.replace(':', ' ')
    testo = testo.replace('\n', ' ').replace('\r', ' ')
    testo = testo.replace(" +", " ")
    testo = testo.replace(" +", " ")
    testo = testo.replace('?', ' ')
    testo = re.sub('[^a-zA-Z]', ' ', testo)
    testo = re.sub(' +', ' ', testo)
    testo = re.sub('\ +', ' ', testo)
    testo = re.sub(r'\s([?.!"](?:\s|$))', r'\1', testo)
    return testo

Pulizia dei dati del dizionario

dizionario["word"] = dizionario["word"].apply(lambda x: preprepara(x)) 
dizionario = dizionario[dizionario["word"] != " "] 
dizionario = dizionario[dizionario["word"] != ""] 
dizionario = {row['word']: row['replacement'] for index, row in dizionario.iterrows()}

Preparazione dei dati di testo da convertire: è stata creata una nuova colonna con la concatenazione del titolo (4 volte) e del sommario. Questo è ciò che verrà convertito in vettori. Faccio questo perché, in questo modo, do più valore al titolo rispetto al contenuto effettivo dell’articolo.

Poi sostituisco le stop words e le parole presenti nel dizionario

maindataset["NLPtext"] = maindataset["title"] + maindataset["title"] + maindataset["content"] + maindataset["title"] + maindataset["title"] 
maindataset["NLPtext"] = maindataset["NLPtext"].str.lower() 
maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: preprepara(str(x))) 
maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: ' '.join([parola for parola in x.split() if parola not in (nltkstop)])) 
maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: sostituisci_parole(str(x), dizionario))

La parte finale della preparazione del testo è la riduzione del testo. Questo viene fatto in questo caso poiché sto addestrando il modello da zero.

La decisione di ridurre o meno dipenderà dal modello utilizzato. Quando si utilizzano modelli preaddestrati come BERT, ciò non è consigliato poiché le parole non corrisponderanno alle parole presenti nelle loro librerie.

def riduzione(testo): parole = word_tokenize(testo) radicali = [snow.stem(parola) for parola in parole] output = ' '.join(radicali) return outputmaindataset["TestoNLP"] = maindataset["TestoNLP"].apply(lambda x: riduzione(x))maindataset['LunghezzaTitolo'] = maindataset["Titolo"].apply(lambda x: len(str(x).split(' ')))maindataset['LunghezzaDescrizione'] = maindataset["Contenuto"].apply(lambda x: len(str(x).split(' ')))maindataset['LunghezzaTesto'] = maindataset["TestoNLP"].apply(lambda x: len(str(x).split(' ')))maindataset = maindataset[maindataset['TestoNLP'].notna()]maindataset = maindataset[maindataset['LunghezzaTitolo']>=4]maindataset = maindataset[maindataset['LunghezzaDescrizione']>=4]maindataset = maindataset[maindataset['LunghezzaTesto']>=4]maindataset = maindataset.reset_index(drop=False)maindataset

Infine, è ora di addestrare il modello doc2vec.

#randomizza il datasettrainset = maindataset.sample(frac=1).reset_index(drop=True)#escludi i testi troppo cortitrainset = trainset[(trainset['TestoNLP'].str.len() >= 5)]#seleziona la colonna del testotrainset = trainset[["TestoNLP"]]#tokenizza e produce il set di addestramentotagged_data = []for index, row in trainset.iterrows(): part = TaggedDocument(words=word_tokenize(row[0]), tags=[str(index)]) tagged_data.append(part)#definisci il modellomodel = Doc2Vec(vector_size=250, min_count=3, epochs=20, dm=1)model.build_vocab(tagged_data)#addestra e salvamodello. Train(tagged_data, total_examples=model.corpus_count, epochs=model.epochs)model.save("d2v.model")print("Modello Salvato")

Nello spirito di limitare la dimensione dei dati e il tempo, filtrerò una fonte di notizie.

maindataset.groupby('pubblicazione').count()['index']

maindatasetF = maindataset[maindataset["pubblicazione"]=="Guardian"]

Ora, vettorizzo le informazioni testuali per la pubblicazione selezionata.

a = []for index, row in maindatasetF.iterrows(): testo_nlp = row['TestoNLP'] ids = row['index'] vettore = model.infer_vector(word_tokenize(testo_nlp)) vettore = pd.DataFrame(vettore).T vettore.index = [ids] a.append(vettore)vettoritestuali = pd.concat(a)vettoritestuali

Standardizza gli embedding e applica PCA (riduci il numero di dimensioni)

def scalaretto(dataset): scaler = StandardScaler() risultatiTrasformati = scaler.fit_transform(dataset) risultatiTrasformati = pd.DataFrame(risultatiTrasformati) risultatiTrasformati.index = dataset.index risultatiTrasformati.columns = dataset.columns return risultatiTrasformatidatasetR = scalaretto(vettoritestuali)def rid_variab(dataset): scaler = PCA(n_components=0.8, svd_solver='full') risultatiTrasformati = dataset.copy() risultatiTrasformati = scaler.fit_transform(risultatiTrasformati) risultatiTrasformati = pd.DataFrame(risultatiTrasformati) risultatiTrasformati.index = dataset.index risultatiTrasformati.columns = risultatiTrasformati.columns.astype(str) return risultatiTrasformatidatasetR = rid_variab(datasetR)

Il primo esercizio che voglio provare ora è una ricerca di similarità. Trova articoli simili all’esempio fornito.

#Trova per indice e stampa l'oggetto di ricerca originale
indice = 95133
testotrovato = maindatasetF[maindatasetF["index"]==indice]["title"]
print(str(testotrovato))
id = indice
print(str(id))
cat = maindatasetF[maindatasetF["index"]==indice]["publication"]
print(str(cat))
trovarembd = datasetR[datasetR.index==id]
#calcola le distanze tra coppie Euclidee ed estrai quelle più simili all'esempio fornito
distanze = pairwise_distances(X=trovarembd, Y=datasetR, metric='euclidean')
distanze = pd.DataFrame(distanze).T
distanze.index = datasetR.index
distanze = distanze.sort_values(0)
distanze = distanze.reset_index(drop=False)
distanze = pd.merge(distanze, maindatasetF[["index","title","publication","content"]], left_on=["index"], right_on=["index"])
pd.options.display.max_colwidth = 100
distanze.head(100)[['index',0,'publication','title']]

Possiamo vedere che i testi estratti hanno senso, sono simili nella natura all’esempio fornito.

Per la clustering, il primo passo è trovare un numero ideale di cluster. A questo punto, vogliamo massimizzare i punteggi di silhouette e Calinski Harabasz mantenendo al contempo un numero logico di cluster (non troppo basso che sarebbe difficile da interpretare o troppo alto che sarebbe troppo granulare).

#Loop per provare modelli e cluster
a = []
X = datasetR.to_numpy(dtype='float')
for ncl in np.arange(2, int(20), 1): 
    clusterer = Birch(n_clusters=int(ncl)) 
    #cattura gli avvisi che riempiono l'output
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        cluster_labels2 = clusterer.fit_predict(X)
        silhouette_avg2 = silhouette_score(X, cluster_labels2)
        calinski2 = calinski_harabasz_score(X, cluster_labels2)
        row = pd.DataFrame({"ncl": [ncl], "silKMeans": [silhouette_avg2], "c_hKMeans": [calinski2],})
        a.append(row)
scores = pd.concat(a, ignore_index=True)
#plot dei risultati
plt.style.use('bmh')
fig, [ax_sil, ax_ch] = plt.subplots(1,2,figsize=(15,7))
ax_sil.plot(scores["ncl"], scores["silKMeans"], 'b-')
ax_ch.plot(scores["ncl"], scores["c_hKMeans"], 'b-')
ax_sil.set_title("Curve di Silhouette")
ax_ch.set_title("Curve di Calinski Harabasz")
ax_sil.set_xlabel('cluster')
ax_sil.set_ylabel('silhouette_avg')
ax_ch.set_xlabel('cluster')
ax_ch.set_ylabel('calinski_harabasz')
ax_ch.legend(loc="upper right")
plt.show()

Scegliamo quindi 5 cluster e avviamo l’algoritmo.

ncl_birch = 5
#ignora gli avvisi che riempiono l'output
with warnings.catch_warnings(): 
    warnings.simplefilter("ignore")
    clusterer2 = Birch(n_clusters=int(ncl_birch))
    cluster_labels2 = clusterer2.fit_predict(X)
n_clusters2 = max(cluster_labels2)
silhouette_avg2 = silhouette_score(X, cluster_labels2)
sample_silhouette_values2 = silhouette_samples(X, cluster_labels2)
finalDF = datasetR.copy()
finalDF["cluster"] = cluster_labels2
finalDF["silhouette"] = sample_silhouette_values2
#plot dei punteggi di silhouette
fig, ax2 = plt.subplots()
ax2.set_xlim([-0.1, 1])
ax2.set_ylim([0, len(X) + (n_clusters2 + 1) * 10])
y_lower = 10
for i in range(min(cluster_labels2),max(cluster_labels2)+1):
    ith_cluster_silhouette_values = sample_silhouette_values2[cluster_labels2 == i]
    ith_cluster_silhouette_values.sort()
    size_cluster_i = ith_cluster_silhouette_values.shape[0]
    y_upper = y_lower + size_cluster_i
    color = cm.nipy_spectral(float(i) / n_clusters2)
    ax2.fill_betweenx(np.arange(y_lower, y_upper), 0, ith_cluster_silhouette_values, facecolor=color, edgecolor=color, alpha=0.7)
    ax2.text(-0.05, y_lower + 0.5 * size_cluster_i, str(i))
    y_lower = y_upper + 10
ax2.set_title("Grafico di Silhouette per Birch")
ax2.set_xlabel("Valori del coefficiente di Silhouette")
ax2.set_ylabel("Etichette dei cluster")
ax2.axvline(x=silhouette_avg2, color="red", linestyle="--")
ax2.set_yticks([])
ax2.set_xticks([-0.1, 0, 0.2, 0.4, 0.6, 0.8, 1])

Questi risultati mi stanno dicendo che il cluster numero 4 potrebbe apparire meno “legato insieme” rispetto agli altri. Al contrario, i cluster numeri 3 e 1 sono ben definiti. Questo è un campione dei risultati.

showDF = finalDF.sort_values(['cluster','silhouette'], ascending=[False,False]).groupby('cluster').head(3)showDF = pd.merge(showDF[['cluster','silhouette']],maindatasetF[["index",'title']], left_index=True ,right_on=["index"])showDF

Puoi vedere che il cluster 4 riguarda le notizie legate alla tecnologia, il cluster 3 riguarda la guerra / eventi internazionali, il cluster 2 riguarda l’intrattenimento, il cluster 1 riguarda lo sport e il cluster 0, come al solito, è un punto che può essere considerato “altro”.