Aplicando modelagem de assuntos ao DHBB#

Neste capítulo vamos explorar ferramentas de modelagem de assuntos e explorar aplicações ao DHBB. Como sempre, começamos com alguns imports familiares.

Hide code cell source
import warnings
warnings.filterwarnings('ignore')
import os, glob, pickle
import numpy as np
import spacy
from spacy import displacy
from sqlalchemy import create_engine, text
from dhbbmining import *
import ipywidgets as widgets
from tqdm import tqdm

Vamos também carregar o modelo de NLP para a língua portuguesa do Spacy:

# Descomente a linha abaixo para instalar
#!python3 -m spacy download pt_core_news_sm
nlp = spacy.load("pt_core_news_sm")
---------------------------------------------------------------------------
OSError                                   Traceback (most recent call last)
Cell In[4], line 1
----> 1 nlp = spacy.load("pt_core_news_sm")

File ~/Documentos/Projects_software/text-mining-cientistas-sociais/.venv/lib/python3.12/site-packages/spacy/__init__.py:51, in load(name, vocab, disable, enable, exclude, config)
     27 def load(
     28     name: Union[str, Path],
     29     *,
   (...)     34     config: Union[Dict[str, Any], Config] = util.SimpleFrozenDict(),
     35 ) -> Language:
     36     """Load a spaCy model from an installed package or a local path.
     37 
     38     name (str): Package name or model path.
   (...)     49     RETURNS (Language): The loaded nlp object.
     50     """
---> 51     return util.load_model(
     52         name,
     53         vocab=vocab,
     54         disable=disable,
     55         enable=enable,
     56         exclude=exclude,
     57         config=config,
     58     )

File ~/Documentos/Projects_software/text-mining-cientistas-sociais/.venv/lib/python3.12/site-packages/spacy/util.py:472, in load_model(name, vocab, disable, enable, exclude, config)
    470 if name in OLD_MODEL_SHORTCUTS:
    471     raise IOError(Errors.E941.format(name=name, full=OLD_MODEL_SHORTCUTS[name]))  # type: ignore[index]
--> 472 raise IOError(Errors.E050.format(name=name))

OSError: [E050] Can't find model 'pt_core_news_sm'. It doesn't seem to be a Python package or a valid path to a data directory.

Agora faremos alguns imports novos, particularmente da biblioteca Gensim, que nos oferece as ferramentas que necessitamos para modelagem de assuntos.

from string import punctuation
from gensim.test.utils import datapath
from gensim import utils
from gensim.models import Word2Vec, word2vec

Para minimizar o uso de memória, vamos construir uma classe para representar o nosso corpus como um iterador, operando diretamente do banco de dados. Desta forma, ao fazer nossas análises, podemos carregar um documento por vez para alimentar os modelos, sem a necessidade de manter todo o corpus na memória, economizando memória RAM.

eng = create_engine("sqlite:///minha_tabela.sqlite")

class DHBBCorpus:
    """
    Iterador de documentos quebrados em frases
    """
    def __init__(self, ndocs=10000):
        self.ndocs = min(7687,ndocs)
        self.counter = 1
    def __iter__(self):
        with eng.connect() as con:
            res = con.execute(text(f'select corpo from resultados limit {self.ndocs};'))
            for doc in res:
                d = self.pre_process(doc[0])
                if self.counter%10 == 0:
                    print (f"Verbete {self.counter} de {6*self.ndocs}\r", end='')
                for s in d:
                    yield s
                self.counter += 1
    def pre_process(self, doc):
        n = nlp(doc, disable=['tagger', 'ner','entity-linker', 'textcat','entity-ruler','merge-noun-chunks','merge-entities','merge-subtokens'])
        results = []
        for sentence in n.sents:
            s = sentence.text.split()
            if not s:
                continue
            results.append([token.strip().strip(punctuation) for token in s if token.strip().strip(punctuation)])
        return results
        

Abaixo um pequeno exemplo de como a classe DHBBCorpus funciona:

DC = DHBBCorpus(5)
for f in DC:
    print(f)
    break
    
['«Floriano', 'Lopes', 'Rubim»', 'nasceu', 'em', 'Alegre', 'ES', 'no', 'dia', '18', 'de', 'janeiro', 'de', '1912', 'filho', 'de', 'Francisco', 'Lopes', 'Rubim', 'funcionário', 'público', 'e', 'de', 'Maria', 'Sílvia', 'Lousada', 'Rubim']

Word2vec#

Vamos começar pelo treinamento de um modelo word2vec. Este modelo itera 6 vezes sobre o corpus logo, devemos ver o contador atingir 46122. Estas repetições são necessárias para permitir a melhor estimação da representação vetorial das palavras em um espaço semântico.

if os.path.exists('dhbb.w2v'):
    model = Word2Vec.load('dhbb.w2v')
else:
    DC = DHBBCorpus()
    model = Word2Vec(sentences=DC, workers=32)
    model.save('dhbb.w2v')

Explorando o modelo#

Como dito anteriormente o modelo do Word2Vec representa cada palavra do vocabulário do corpus como um vetor. Vejamos as dez primeiras palavras:

for i, word in enumerate(model.wv.index_to_key):
    if i == 10:
        break
    print(word)
de
a
do
e
da
o
em
que
no
na

Enquanto vetores podemos realizar operações aritméticas entre eles. Por exemplo se subtrairmos os vetores de Deputado e deputado encontraremos que a magnitude do vetor resultante é bem pequena, pois estão muito próximos no espaço vetorial:

vec = model.wv['Deputado'] - model.wv['deputado']
print(f"Todos os vetores tem dimensão {len(vec)}")
np.linalg.norm(vec)
Todos os vetores tem dimensão 100
24.32071

O tamanho total do vocabulário (palavras distintas) é de:

print(f"{len(model.wv.index_to_key)} palavras")
38762 palavras

Usando a mesma lógica de operações sobre os vetores, podemos usar palavras como representantes de seu significado, efetivamente estabelecendo um cálculo conceitual. Por exemplo: Abaixo vamos buscar as palavras mais similares semanticamente com o conceito de direita e ao mesmos antagônicas ao conceito de “Comunista”. Como o corpus do DHBB tem um claro viés político, vemos o termo direita tomar a conotação política usual.

model.wv.most_similar(positive=['direita'],negative=['Comunista'], topn=20)
[('progressistas', 0.5529391169548035),
 ('segmentos', 0.5388556718826294),
 ('conservadores', 0.5193644165992737),
 ('antigetulistas', 0.515348494052887),
 ('setores', 0.5146178603172302),
 ('nacionalistas', 0.5058426856994629),
 ('esquerda', 0.5054914951324463),
 ('oposição', 0.4820389747619629),
 ('extrema', 0.47334492206573486),
 ('oposicionistas', 0.4595625698566437),
 ('adeptos', 0.44704267382621765),
 ('udenistas', 0.44662249088287354),
 ('centro-esquerda', 0.44410112500190735),
 ('radicais', 0.43817466497421265),
 ('pessedistas', 0.4352889060974121),
 ('moderados', 0.4338815212249756),
 ('liberais', 0.43283340334892273),
 ('jovens', 0.43128761649131775),
 ('grupos', 0.42958810925483704),
 ('ambientalistas', 0.42890769243240356)]

O número total de tokens no corpus é de:

model.corpus_total_words
8713256

Visualizando os vetores de palavras#

Como os palavras estão inseridas um espaço vetorial de dimensão 100, fica difícil visualizar o seu posicionamento relativo mas podemos nos utilizar de métodos de redução de dimensionalidade com o TSNE, para visualizarmos um pequeno numero (200) delas.

from sklearn.decomposition import IncrementalPCA    # inital reduction
from sklearn.manifold import TSNE                   # final reduction
import numpy as np                                  # array handling


def reduce_dimensions(model):
    num_dimensions = 2  # final num dimensions (2D, 3D, etc)

    vectors = [] # positions in vector space
    labels = [] # keep track of words to label our data again later
    i = 0
    for word in model.wv.index_to_key:
        vectors.append(model.wv[word])
        labels.append(word)
        i+=1
        if i>200:
            break

    # convert both lists into numpy vectors for reduction
    vectors = np.asarray(vectors)
    labels = np.asarray(labels)

    # reduce using t-SNE
    vectors = np.asarray(vectors)
    tsne = TSNE(n_components=num_dimensions, random_state=0)
    vectors = tsne.fit_transform(vectors)

    x_vals = [v[0] for v in vectors]
    y_vals = [v[1] for v in vectors]
    return x_vals, y_vals, labels


x_vals, y_vals, labels = reduce_dimensions(model)

def plot_with_plotly(x_vals, y_vals, labels, plot_in_notebook=True):
    from plotly.offline import init_notebook_mode, iplot, plot
    import plotly.graph_objs as go
    layout = go.Layout(
        autosize=False,
        width=800,
        height=600
    )

    trace = go.Scatter(x=x_vals, y=y_vals, mode='text', text=labels, opacity=0.8)
    data = [trace]
    fig = go.Figure(
    data= data,
    layout= layout)

    if plot_in_notebook:
        init_notebook_mode(connected=True)
        iplot(fig, filename='word-embedding-plot')
    else:
        plot(fig, filename='word-embedding-plot.html')
plot_with_plotly(x_vals, y_vals, labels)

Outros exemplos de combinação de conceitos.

model.wv.most_similar(positive=['deputado', 'mulher'], negative=['homem'])
[('deputada', 0.707239031791687),
 ('senadora', 0.6504750847816467),
 ('governadora', 0.5311956405639648),
 ('vaga', 0.5001533031463623),
 ('senador', 0.49622222781181335),
 ('bancada', 0.49382296204566956),
 ('prefeita', 0.4898069202899933),
 ('esposa', 0.4874173700809479),
 ('vereadora', 0.477497398853302),
 ('ex-deputado', 0.4704161286354065)]
model.wv.most_similar(positive=['Sergipe', 'Salvador'], negative=['Bahia'])
[('Aracaju', 0.7568933963775635),
 ('Fortaleza', 0.7413235306739807),
 ('Maceió', 0.7330026030540466),
 ('Manaus', 0.729759693145752),
 ('Curitiba', 0.722693920135498),
 ('Teresina', 0.7146587371826172),
 ('Cuiabá', 0.7008069753646851),
 ('Dourados', 0.6986944675445557),
 ('Belém', 0.6970897912979126),
 ('Goiânia', 0.6968613862991333)]
model.wv.most_similar(positive=['congressista', 'política'], negative=['homem'])
[('independente”', 0.5179454684257507),
 ('Iniciando-se', 0.47886374592781067),
 ('turística', 0.477364718914032),
 ('duradouras', 0.4718947410583496),
 ('“política', 0.4573584198951721),
 ('induzida', 0.45302215218544006),
 ('marxista”', 0.4503825306892395),
 ('conflituosas', 0.45008936524391174),
 ('pragmática', 0.4451919496059418),
 ('stalinista', 0.4449922740459442)]
model.wv['congressista'] + model.wv['política']-model.wv['homem']
array([-1.1804812 ,  0.17904007,  2.7701726 ,  2.2788234 , -2.3939579 ,
       -1.6261489 ,  3.037264  , -1.5370936 ,  0.1686287 ,  1.7679174 ,
       -0.6206008 , -1.4379971 ,  0.9625195 ,  5.3786445 , -1.5540149 ,
       -0.00777411, -1.401691  , -4.2874527 , -0.82898766,  1.8527349 ,
        1.1073897 ,  0.4124229 , -1.7202759 , -1.9285159 ,  0.22249055,
       -0.1637733 ,  0.3505941 , -1.6477919 ,  3.5996528 , -1.884695  ,
        0.70941716,  2.4855223 ,  0.19784053,  1.0554858 , -2.0812526 ,
        0.25975254,  1.0439771 ,  0.40970242,  0.78404856, -2.2612848 ,
        1.7169695 ,  0.73526394,  1.4105549 ,  4.066178  , -0.40555423,
        1.8521545 ,  3.7054353 ,  1.3822607 , -4.056506  ,  1.9932911 ,
        0.89136565, -2.6281335 ,  3.6849546 ,  3.6759586 , -0.3893448 ,
       -0.93963474,  3.059989  ,  0.07065499, -0.69516784, -1.2888556 ,
        0.92977023, -1.6649121 ,  2.6862006 , -2.4784298 ,  1.8600826 ,
        0.779212  ,  2.1920981 , -1.9809456 , -2.688896  ,  0.60114765,
       -2.3174565 ,  0.08518887,  0.43477178,  1.2217847 ,  0.9667772 ,
        1.8949485 ,  1.2996533 ,  2.6153111 , -0.04217112,  0.72740364,
       -0.7117096 , -0.73151827,  0.16029513,  0.06895769, -0.17937306,
        0.48638743, -0.52041715, -2.0970736 , -1.1521842 , -3.9579785 ,
        0.51819354,  0.12455559,  2.2292786 ,  0.33785737, -0.4980077 ,
       -2.9939485 , -2.5539153 , -1.4805562 ,  3.1075735 ,  0.99266756],
      dtype=float32)

Word Movers Distance#

Este método permite que calculemos a distância entre dois documentos mesmo que estes não contenham palavras em comum. Este método foi proposto no artigo “From Word Embeddings To Document Distances”.

DC = list(DHBBCorpus(5))
model.wv.wmdistance(document1=DC[1],document2=DC[2])
1.0211877866713601
print(DC[1])
print(DC[2])
['Bacharel', 'em', 'ciências', 'jurídicas', 'e', 'sociais', 'pela', 'Faculdade', 'de', 'Direito', 'da', 'Universidade', 'do', 'Espírito', 'Santo', 'formou-se', 'também', 'na', 'Escola', 'de', 'Educação', 'Física', 'do', 'Rio', 'de', 'Janeiro', 'então', 'Distrito', 'Federal', 'em', '1941']
['Nesse', 'mesmo', 'ano', 'ingressou', 'na', 'Força', 'Policial', 'do', 'Espírito', 'Santo', 'onde', 'faria', 'carreira']

Vamos comparar o documento 1 acima (Era presidido por João Câncio da Silva) com outro sem nenhuma palavra em comum, e veremos que é possível calcular uma distância.

model.wv.wmdistance(document1=DC[1],document2='frequentou a escola religiosa em Niteroi e formou-se em 1935 pela UFF'.split())
0.9262080324851342

Visualizando a Vizinança Semântica#

Por meio do programa SemanticTree, podemos construir uma visualização animada da vizinhança semântica em uma rede.

!semanticTree -h
usage: semanticTree [-h] [-w W] [-n N] [-s SIM] [-S SIZE]
                    [-o {image,animation}]
                    model

Visualize the semantic neighborhood of a term. given a Gensim's Word2vec
trained model

positional arguments:
  model                 file name of a Gensim's Word2vec model.

options:
  -h, --help            show this help message and exit
  -w W                  the word or bi_gram to analyze
  -n N                  Max number of neighbors to scan.
  -s SIM, --sim SIM     minimum similarity to be a neighbor
  -S SIZE, --size SIZE  Maximum size of the three
  -o {image,animation}, --output {image,animation}
                        Type of visualization

Para aplicar ao nosso modelo, focando no conceito de política, o comando abaixo pode ser usado:

!semanticTree -w política dhbb.w2v
Loading Data...
Detected Word2Vec model
Building graph...

rede