thematizer / src /Top2Vec.py
nlomov's picture
Added all source code
ab33b80
Raw
History Blame Contribute Delete
98.5 kB
# Author: Dimo Angelov
#
# License: BSD 3 clause
import logging
import numpy as np
import pandas as pd
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from gensim.utils import simple_preprocess
from gensim.parsing.preprocessing import strip_tags
import umap
import hdbscan
from wordcloud import WordCloud
import matplotlib.pyplot as plt
from joblib import dump, load
from sklearn.cluster import dbscan
import tempfile
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.preprocessing import normalize
from scipy.special import softmax
try:
import hnswlib
_HAVE_HNSWLIB = True
except ImportError:
_HAVE_HNSWLIB = False
try:
import tensorflow as tf
import tensorflow_hub as hub
import tensorflow_text
_HAVE_TENSORFLOW = True
except ImportError:
_HAVE_TENSORFLOW = False
try:
from sentence_transformers import SentenceTransformer
_HAVE_TORCH = True
except ImportError:
_HAVE_TORCH = False
logger = logging.getLogger('top2vec')
logger.setLevel(logging.WARNING)
sh = logging.StreamHandler()
sh.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logger.addHandler(sh)
def default_tokenizer(doc):
"""Tokenize documents for training and remove too long/short words"""
return simple_preprocess(strip_tags(doc), deacc=True)
class Top2Vec:
"""
Top2Vec
Creates jointly embedded topic, document and word vectors.
Parameters
----------
embedding_model: string
This will determine which model is used to generate the document and
word embeddings. The valid string options are:
* doc2vec
* universal-sentence-encoder
* universal-sentence-encoder-multilingual
* distiluse-base-multilingual-cased
For large data sets and data sets with very unique vocabulary doc2vec
could produce better results. This will train a doc2vec model from
scratch. This method is language agnostic. However multiple languages
will not be aligned.
Using the universal sentence encoder options will be much faster since
those are pre-trained and efficient models. The universal sentence
encoder options are suggested for smaller data sets. They are also
good options for large data sets that are in English or in languages
covered by the multilingual model. It is also suggested for data sets
that are multilingual.
For more information on universal-sentence-encoder visit:
https://tfhub.dev/google/universal-sentence-encoder/4
For more information on universal-sentence-encoder-multilingual visit:
https://tfhub.dev/google/universal-sentence-encoder-multilingual/3
The distiluse-base-multilingual-cased pre-trained sentence transformer
is suggested for multilingual datasets and languages that are not
covered by the multilingual universal sentence encoder. The
transformer is significantly slower than the universal sentence
encoder options.
For more informati ond istiluse-base-multilingual-cased visit:
https://www.sbert.net/docs/pretrained_models.html
embedding_model_path: string (Optional)
Pre-trained embedding models will be downloaded automatically by
default. However they can also be uploaded from a file that is in the
location of embedding_model_path.
Warning: the model at embedding_model_path must match the
embedding_model parameter type.
documents: List of str
Input corpus, should be a list of strings.
min_count: int (Optional, default 50)
Ignores all words with total frequency lower than this. For smaller
corpora a smaller min_count will be necessary.
speed: string (Optional, default 'learn')
This parameter is only used when using doc2vec as embedding_model.
It will determine how fast the model takes to train. The
fast-learn option is the fastest and will generate the lowest quality
vectors. The learn option will learn better quality vectors but take
a longer time to train. The deep-learn option will learn the best
quality vectors but will take significant time to train. The valid
string speed options are:
* fast-learn
* learn
* deep-learn
use_corpus_file: bool (Optional, default False)
This parameter is only used when using doc2vec as embedding_model.
Setting use_corpus_file to True can sometimes provide speedup for
large datasets when multiple worker threads are available. Documents
are still passed to the model as a list of str, the model will create
a temporary corpus file for training.
document_ids: List of str, int (Optional)
A unique value per document that will be used for referring to
documents in search results. If ids are not given to the model, the
index of each document in the original corpus will become the id.
keep_documents: bool (Optional, default True)
If set to False documents will only be used for training and not saved
as part of the model. This will reduce model size. When using search
functions only document ids will be returned, not the actual
documents.
workers: int (Optional)
The amount of worker threads to be used in training the model. Larger
amount will lead to faster training.
tokenizer: callable (Optional, default None)
Override the default tokenization method. If None then
gensim.utils.simple_preprocess will be used.
use_embedding_model_tokenizer: bool (Optional, default False)
If using an embedding model other than doc2vec, use the model's
tokenizer for document embedding. If set to True the tokenizer, either
default or passed callable will be used to tokenize the text to
extract the vocabulary for word embedding.
umap_args: dict (Optional, default None)
Pass custom arguments to UMAP.
hdbscan_args: dict (Optional, default None)
Pass custom arguments to HDBSCAN.
verbose: bool (Optional, default True)
Whether to print status data during training.
"""
def __init__(self,
documents,
min_count=50,
embedding_model='doc2vec',
embedding_model_path=None,
speed='learn',
use_corpus_file=False,
document_ids=None,
keep_documents=True,
workers=None,
tokenizer=None,
use_embedding_model_tokenizer=False,
umap_args=None,
hdbscan_args=None,
verbose=True
):
if verbose:
logger.setLevel(logging.DEBUG)
self.verbose = True
else:
logger.setLevel(logging.WARNING)
self.verbose = False
if tokenizer is None:
tokenizer = default_tokenizer
# validate documents
if not (isinstance(documents, list) or isinstance(documents, np.ndarray)):
raise ValueError("Documents need to be a list of strings")
if not all((isinstance(doc, str) or isinstance(doc, np.str_)) for doc in documents):
raise ValueError("Documents need to be a list of strings")
if keep_documents:
self.documents = np.array(documents, dtype="object")
else:
self.documents = None
# validate document ids
if document_ids is not None:
if not (isinstance(document_ids, list) or isinstance(document_ids, np.ndarray)):
raise ValueError("Documents ids need to be a list of str or int")
if len(documents) != len(document_ids):
raise ValueError("Document ids need to match number of documents")
elif len(document_ids) != len(set(document_ids)):
raise ValueError("Document ids need to be unique")
if all((isinstance(doc_id, str) or isinstance(doc_id, np.str_)) for doc_id in document_ids):
self.doc_id_type = np.str_
elif all((isinstance(doc_id, int) or isinstance(doc_id, np.int_)) for doc_id in document_ids):
self.doc_id_type = np.int_
else:
raise ValueError("Document ids need to be str or int")
self.document_ids_provided = True
self.document_ids = np.array(document_ids)
self.doc_id2index = dict(zip(document_ids, list(range(0, len(document_ids)))))
else:
self.document_ids_provided = False
self.document_ids = np.array(range(0, len(documents)))
self.doc_id2index = dict(zip(self.document_ids, list(range(0, len(self.document_ids)))))
self.doc_id_type = np.int_
acceptable_embedding_models = ["universal-sentence-encoder-multilingual",
"universal-sentence-encoder",
"distiluse-base-multilingual-cased"]
self.embedding_model_path = embedding_model_path
if embedding_model == 'doc2vec':
# validate training inputs
if speed == "fast-learn":
hs = 0
negative = 5
epochs = 40
elif speed == "learn":
hs = 1
negative = 0
epochs = 40
elif speed == "deep-learn":
hs = 1
negative = 0
epochs = 400
elif speed == "test-learn":
hs = 0
negative = 5
epochs = 1
else:
raise ValueError("speed parameter needs to be one of: fast-learn, learn or deep-learn")
if workers is None:
pass
elif isinstance(workers, int):
pass
else:
raise ValueError("workers needs to be an int")
doc2vec_args = {"vector_size": 300,
"min_count": min_count,
"window": 15,
"sample": 1e-5,
"negative": negative,
"hs": hs,
"epochs": epochs,
"dm": 0,
"dbow_words": 1}
if workers is not None:
doc2vec_args["workers"] = workers
logger.info('Pre-processing documents for training')
if use_corpus_file:
processed = [' '.join(tokenizer(doc)) for doc in documents]
lines = "\n".join(processed)
temp = tempfile.NamedTemporaryFile(mode='w+t')
temp.write(lines)
doc2vec_args["corpus_file"] = temp.name
else:
train_corpus = [TaggedDocument(tokenizer(doc), [i]) for i, doc in enumerate(documents)]
doc2vec_args["documents"] = train_corpus
logger.info('Creating joint document/word embedding')
self.embedding_model = 'doc2vec'
self.model = Doc2Vec(**doc2vec_args)
if use_corpus_file:
temp.close()
elif embedding_model in acceptable_embedding_models:
self.embed = None
self.embedding_model = embedding_model
self._check_import_status()
logger.info('Pre-processing documents for training')
# preprocess documents
tokenized_corpus = [tokenizer(doc) for doc in documents]
def return_doc(doc):
return doc
# preprocess vocabulary
vectorizer = CountVectorizer(tokenizer=return_doc, preprocessor=return_doc)
doc_word_counts = vectorizer.fit_transform(tokenized_corpus)
words = vectorizer.get_feature_names()
word_counts = np.array(np.sum(doc_word_counts, axis=0).tolist()[0])
vocab_inds = np.where(word_counts > min_count)[0]
if len(vocab_inds) == 0:
raise ValueError(f"A min_count of {min_count} results in "
f"all words being ignored, choose a lower value.")
self.vocab = [words[ind] for ind in vocab_inds]
self._check_model_status()
logger.info('Creating joint document/word embedding')
# embed words
self.word_indexes = dict(zip(self.vocab, range(len(self.vocab))))
self.word_vectors = self._l2_normalize(np.array(self.embed(self.vocab)))
# embed documents
if use_embedding_model_tokenizer:
self.document_vectors = self._embed_documents(documents)
else:
train_corpus = [' '.join(tokens) for tokens in tokenized_corpus]
self.document_vectors = self._embed_documents(train_corpus)
else:
raise ValueError(f"{embedding_model} is an invalid embedding model.")
# create 5D embeddings of documents
logger.info('Creating lower dimension embedding of documents')
if umap_args is None:
umap_args = {'n_neighbors': 15,
'n_components': 5,
'metric': 'cosine'}
umap_model = umap.UMAP(**umap_args).fit(self._get_document_vectors(norm=False))
# find dense areas of document vectors
logger.info('Finding dense areas of documents')
if hdbscan_args is None:
hdbscan_args = {'min_cluster_size': 15,
'metric': 'euclidean',
'cluster_selection_method': 'eom'}
cluster = hdbscan.HDBSCAN(**hdbscan_args).fit(umap_model.embedding_)
# calculate topic vectors from dense areas of documents
logger.info('Finding topics')
# create topic vectors
self._create_topic_vectors(cluster.labels_)
# deduplicate topics
self._deduplicate_topics()
# find topic words and scores
self.topic_words, self.topic_word_scores = self._find_topic_words_and_scores(topic_vectors=self.topic_vectors)
# assign documents to topic
self.doc_top, self.doc_dist = self._calculate_documents_topic(self.topic_vectors,
self._get_document_vectors())
# calculate topic sizes
self.topic_sizes = self._calculate_topic_sizes(hierarchy=False)
# re-order topics
self._reorder_topics(hierarchy=False)
# initialize variables for hierarchical topic reduction
self.topic_vectors_reduced = None
self.doc_top_reduced = None
self.doc_dist_reduced = None
self.topic_sizes_reduced = None
self.topic_words_reduced = None
self.topic_word_scores_reduced = None
self.hierarchy = None
# initialize document indexing variables
self.document_index = None
self.serialized_document_index = None
self.documents_indexed = False
self.index_id2doc_id = None
self.doc_id2index_id = None
# initialize word indexing variables
self.word_index = None
self.serialized_word_index = None
self.words_indexed = False
def save(self, file):
"""
Saves the current model to the specified file.
Parameters
----------
file: str
File where model will be saved.
"""
document_index_temp = None
word_index_temp = None
# do not save sentence encoders and sentence transformers
if self.embedding_model != "doc2vec":
self.embed = None
# serialize document index so that it can be saved
if self.documents_indexed:
temp = tempfile.NamedTemporaryFile(mode='w+b')
self.document_index.save_index(temp.name)
self.serialized_document_index = temp.read()
temp.close()
document_index_temp = self.document_index
self.document_index = None
# serialize word index so that it can be saved
if self.words_indexed:
temp = tempfile.NamedTemporaryFile(mode='w+b')
self.word_index.save_index(temp.name)
self.serialized_word_index = temp.read()
temp.close()
word_index_temp = self.word_index
self.word_index = None
dump(self, file)
self.document_index = document_index_temp
self.word_index = word_index_temp
@classmethod
def load(cls, file):
"""
Load a pre-trained model from the specified file.
Parameters
----------
file: str
File where model will be loaded from.
"""
top2vec_model = load(file)
# load document index
if top2vec_model.documents_indexed:
if not _HAVE_HNSWLIB:
raise ImportError(f"Cannot load document index.\n\n"
"Try: pip install top2vec[indexing]\n\n"
"Alternatively try: pip install hnswlib")
temp = tempfile.NamedTemporaryFile(mode='w+b')
temp.write(top2vec_model.serialized_document_index)
if top2vec_model.embedding_model == 'doc2vec':
try:
document_vectors = top2vec_model.model.docvecs.vectors_docs
except:
document_vectors = top2vec_model.model.docvecs.vectors
else:
document_vectors = top2vec_model.document_vectors
top2vec_model.document_index = hnswlib.Index(space='ip',
dim=document_vectors.shape[1])
top2vec_model.document_index.load_index(temp.name, max_elements=document_vectors.shape[0])
temp.close()
top2vec_model.serialized_document_index = None
# load word index
if top2vec_model.words_indexed:
if not _HAVE_HNSWLIB:
raise ImportError(f"Cannot load word index.\n\n"
"Try: pip install top2vec[indexing]\n\n"
"Alternatively try: pip install hnswlib")
temp = tempfile.NamedTemporaryFile(mode='w+b')
temp.write(top2vec_model.serialized_word_index)
if top2vec_model.embedding_model == 'doc2vec':
word_vectors = top2vec_model.model.wv.vectors
else:
word_vectors = top2vec_model.word_vectors
top2vec_model.word_index = hnswlib.Index(space='ip',
dim=word_vectors.shape[1])
top2vec_model.word_index.load_index(temp.name, max_elements=word_vectors.shape[0])
temp.close()
top2vec_model.serialized_word_index = None
return top2vec_model
@staticmethod
def _l2_normalize(vectors):
if vectors.ndim == 2:
return normalize(vectors)
else:
return normalize(vectors.reshape(1, -1))[0]
def _embed_documents(self, train_corpus):
self._check_import_status()
self._check_model_status()
# embed documents
batch_size = 500
document_vectors = []
current = 0
batches = int(len(train_corpus) / batch_size)
extra = len(train_corpus) % batch_size
for ind in range(0, batches):
document_vectors.append(self.embed(train_corpus[current:current + batch_size]))
current += batch_size
if extra > 0:
document_vectors.append(self.embed(train_corpus[current:current + extra]))
document_vectors = self._l2_normalize(np.array(np.vstack(document_vectors)))
return document_vectors
def _embed_query(self, query):
self._check_import_status()
self._check_model_status()
return self._l2_normalize(np.array(self.embed(query)[0]))
def _set_document_vectors(self, document_vectors):
if self.embedding_model == 'doc2vec':
self.model.docvecs.vectors_docs = document_vectors
else:
self.document_vectors = document_vectors
def _get_document_vectors(self, norm=True):
if self.embedding_model == 'doc2vec':
if norm:
self.model.docvecs.init_sims()
try:
return self.model.docvecs.vectors_docs_norm
except:
return self.model.docvecs.get_normed_vectors()
else:
try:
return self.model.docvecs.vectors_docs
except:
return self.model.docvecs.vectors
else:
return self.document_vectors
def _index2word(self, index):
if self.embedding_model == 'doc2vec':
try:
return self.model.wv.index2word[index]
except:
return self.model.wv.index_to_key[index]
else:
return self.vocab[index]
def _get_word_vectors(self):
if self.embedding_model == 'doc2vec':
self.model.wv.init_sims()
try:
return self.model.wv.vectors_norm
except:
return self.model.wv.get_normed_vectors()
else:
return self.word_vectors
def _create_topic_vectors(self, cluster_labels):
unique_labels = set(cluster_labels)
if -1 in unique_labels:
unique_labels.remove(-1)
self.topic_vectors = self._l2_normalize(
np.vstack([self._get_document_vectors(norm=False)[np.where(cluster_labels == label)[0]]
.mean(axis=0) for label in unique_labels]))
def _deduplicate_topics(self):
core_samples, labels = dbscan(X=self.topic_vectors,
eps=0.1,
min_samples=2,
metric="cosine")
duplicate_clusters = set(labels)
if len(duplicate_clusters) > 1 or -1 not in duplicate_clusters:
# unique topics
unique_topics = self.topic_vectors[np.where(labels == -1)[0]]
if -1 in duplicate_clusters:
duplicate_clusters.remove(-1)
# merge duplicate topics
for unique_label in duplicate_clusters:
unique_topics = np.vstack(
[unique_topics, self._l2_normalize(self.topic_vectors[np.where(labels == unique_label)[0]]
.mean(axis=0))])
self.topic_vectors = unique_topics
def _calculate_topic_sizes(self, hierarchy=False):
if hierarchy:
topic_sizes = pd.Series(self.doc_top_reduced).value_counts()
else:
topic_sizes = pd.Series(self.doc_top).value_counts()
return topic_sizes
def _reorder_topics(self, hierarchy=False):
if hierarchy:
self.topic_vectors_reduced = self.topic_vectors_reduced[self.topic_sizes_reduced.index]
self.topic_words_reduced = self.topic_words_reduced[self.topic_sizes_reduced.index]
self.topic_word_scores_reduced = self.topic_word_scores_reduced[self.topic_sizes_reduced.index]
old2new = dict(zip(self.topic_sizes_reduced.index, range(self.topic_sizes_reduced.index.shape[0])))
self.doc_top_reduced = np.array([old2new[i] for i in self.doc_top_reduced])
self.hierarchy = [self.hierarchy[i] for i in self.topic_sizes_reduced.index]
self.topic_sizes_reduced.reset_index(drop=True, inplace=True)
else:
self.topic_vectors = self.topic_vectors[self.topic_sizes.index]
self.topic_words = self.topic_words[self.topic_sizes.index]
self.topic_word_scores = self.topic_word_scores[self.topic_sizes.index]
old2new = dict(zip(self.topic_sizes.index, range(self.topic_sizes.index.shape[0])))
self.doc_top = np.array([old2new[i] for i in self.doc_top])
self.topic_sizes.reset_index(drop=True, inplace=True)
@staticmethod
def _calculate_documents_topic(topic_vectors, document_vectors, dist=True, num_topics=None):
batch_size = 10000
doc_top = []
if dist:
doc_dist = []
if document_vectors.shape[0] > batch_size:
current = 0
batches = int(document_vectors.shape[0] / batch_size)
extra = document_vectors.shape[0] % batch_size
for ind in range(0, batches):
res = np.inner(document_vectors[current:current + batch_size], topic_vectors)
if num_topics is None:
doc_top.extend(np.argmax(res, axis=1))
if dist:
doc_dist.extend(np.max(res, axis=1))
else:
doc_top.extend(np.flip(np.argsort(res), axis=1)[:, :num_topics])
if dist:
doc_dist.extend(np.flip(np.sort(res), axis=1)[:, :num_topics])
current += batch_size
if extra > 0:
res = np.inner(document_vectors[current:current + extra], topic_vectors)
if num_topics is None:
doc_top.extend(np.argmax(res, axis=1))
if dist:
doc_dist.extend(np.max(res, axis=1))
else:
doc_top.extend(np.flip(np.argsort(res), axis=1)[:, :num_topics])
if dist:
doc_dist.extend(np.flip(np.sort(res), axis=1)[:, :num_topics])
if dist:
doc_dist = np.array(doc_dist)
else:
res = np.inner(document_vectors, topic_vectors)
if num_topics is None:
doc_top = np.argmax(res, axis=1)
if dist:
doc_dist = np.max(res, axis=1)
else:
doc_top.extend(np.flip(np.argsort(res), axis=1)[:, :num_topics])
if dist:
doc_dist.extend(np.flip(np.sort(res), axis=1)[:, :num_topics])
if num_topics is not None:
doc_top = np.array(doc_top)
if dist:
doc_dist = np.array(doc_dist)
if dist:
return doc_top, doc_dist
else:
return doc_top
def _find_topic_words_and_scores(self, topic_vectors):
topic_words = []
topic_word_scores = []
res = np.inner(topic_vectors, self._get_word_vectors())
top_words = np.flip(np.argsort(res, axis=1), axis=1)
top_scores = np.flip(np.sort(res, axis=1), axis=1)
for words, scores in zip(top_words, top_scores):
topic_words.append([self._index2word(i) for i in words[0:50]])
topic_word_scores.append(scores[0:50])
topic_words = np.array(topic_words)
topic_word_scores = np.array(topic_word_scores)
return topic_words, topic_word_scores
def _assign_documents_to_topic(self, document_vectors, hierarchy=False):
if hierarchy:
doc_top_new, doc_dist_new = self._calculate_documents_topic(self.topic_vectors_reduced,
document_vectors,
dist=True)
self.doc_top_reduced = np.append(self.doc_top_reduced, doc_top_new)
self.doc_dist_reduced = np.append(self.doc_dist_reduced, doc_dist_new)
topic_sizes_new = pd.Series(doc_top_new).value_counts()
for top in topic_sizes_new.index.tolist():
self.topic_sizes_reduced[top] += topic_sizes_new[top]
self.topic_sizes_reduced.sort_values(ascending=False, inplace=True)
self._reorder_topics(hierarchy)
else:
doc_top_new, doc_dist_new = self._calculate_documents_topic(self.topic_vectors, document_vectors, dist=True)
self.doc_top = np.append(self.doc_top, doc_top_new)
self.doc_dist = np.append(self.doc_dist, doc_dist_new)
topic_sizes_new = pd.Series(doc_top_new).value_counts()
for top in topic_sizes_new.index.tolist():
self.topic_sizes[top] += topic_sizes_new[top]
self.topic_sizes.sort_values(ascending=False, inplace=True)
self._reorder_topics(hierarchy)
def _unassign_documents_from_topic(self, doc_indexes, hierarchy=False):
if hierarchy:
doc_top_remove = self.doc_top_reduced[doc_indexes]
self.doc_top_reduced = np.delete(self.doc_top_reduced, doc_indexes, 0)
self.doc_dist_reduced = np.delete(self.doc_dist_reduced, doc_indexes, 0)
topic_sizes_remove = pd.Series(doc_top_remove).value_counts()
for top in topic_sizes_remove.index.tolist():
self.topic_sizes_reduced[top] -= topic_sizes_remove[top]
self.topic_sizes_reduced.sort_values(ascending=False, inplace=True)
self._reorder_topics(hierarchy)
else:
doc_top_remove = self.doc_top[doc_indexes]
self.doc_top = np.delete(self.doc_top, doc_indexes, 0)
self.doc_dist = np.delete(self.doc_dist, doc_indexes, 0)
topic_sizes_remove = pd.Series(doc_top_remove).value_counts()
for top in topic_sizes_remove.index.tolist():
self.topic_sizes[top] -= topic_sizes_remove[top]
self.topic_sizes.sort_values(ascending=False, inplace=True)
self._reorder_topics(hierarchy)
def _get_document_ids(self, doc_index):
return self.document_ids[doc_index]
def _get_document_indexes(self, doc_ids):
if self.document_ids is None:
return doc_ids
else:
return [self.doc_id2index[doc_id] for doc_id in doc_ids]
def _words2word_vectors(self, keywords):
return self._get_word_vectors()[[self._word2index(word) for word in keywords]]
def _word2index(self, word):
if self.embedding_model == 'doc2vec':
return self.model.wv.vocab[word].index
else:
return self.word_indexes[word]
def _get_combined_vec(self, vecs, vecs_neg):
combined_vector = np.zeros(self._get_document_vectors().shape[1], dtype=np.float64)
for vec in vecs:
combined_vector += vec
for vec in vecs_neg:
combined_vector -= vec
combined_vector /= (len(vecs) + len(vecs_neg))
combined_vector = self._l2_normalize(combined_vector)
return combined_vector
@staticmethod
def _search_vectors_by_vector(vectors, vector, num_res):
ranks = np.inner(vectors, vector)
indexes = np.flip(np.argsort(ranks)[-num_res:])
scores = np.array([ranks[res] for res in indexes])
return indexes, scores
@staticmethod
def _check_hnswlib_status():
if not _HAVE_HNSWLIB:
raise ImportError(f"Indexing is not available.\n\n"
"Try: pip install top2vec[indexing]\n\n"
"Alternatively try: pip install hnswlib")
def _check_document_index_status(self):
if self.document_index is None:
raise ImportError("There is no document index.\n\n"
"Call index_document_vectors method before setting use_index=True.")
def _check_word_index_status(self):
if self.word_index is None:
raise ImportError("There is no word index.\n\n"
"Call index_word_vectors method before setting use_index=True.")
def _check_import_status(self):
if self.embedding_model != 'distiluse-base-multilingual-cased':
if not _HAVE_TENSORFLOW:
raise ImportError(f"{self.embedding_model} is not available.\n\n"
"Try: pip install top2vec[sentence_encoders]\n\n"
"Alternatively try: pip install tensorflow tensorflow_hub tensorflow_text")
else:
if not _HAVE_TORCH:
raise ImportError(f"{self.embedding_model} is not available.\n\n"
"Try: pip install top2vec[sentence_transformers]\n\n"
"Alternatively try: pip install torch sentence_transformers")
def _check_model_status(self):
if self.embed is None:
if self.verbose is False:
logger.setLevel(logging.DEBUG)
if self.embedding_model != "distiluse-base-multilingual-cased":
if self.embedding_model_path is None:
logger.info(f'Downloading {self.embedding_model} model')
if self.embedding_model == "universal-sentence-encoder-multilingual":
module = "https://tfhub.dev/google/universal-sentence-encoder-multilingual/3"
else:
module = "https://tfhub.dev/google/universal-sentence-encoder/4"
else:
logger.info(f'Loading {self.embedding_model} model at {self.embedding_model_path}')
module = self.embedding_model_path
self.embed = hub.load(module)
else:
if self.embedding_model_path is None:
logger.info(f'Downloading {self.embedding_model} model')
module = 'distiluse-base-multilingual-cased'
else:
logger.info(f'Loading {self.embedding_model} model at {self.embedding_model_path}')
module = self.embedding_model_path
model = SentenceTransformer(module)
self.embed = model.encode
if self.verbose is False:
logger.setLevel(logging.WARNING)
@staticmethod
def _less_than_zero(num, var_name):
if num < 0:
raise ValueError(f"{var_name} cannot be less than 0.")
def _validate_hierarchical_reduction(self):
if self.hierarchy is None:
raise ValueError("Hierarchical topic reduction has not been performed.")
def _validate_hierarchical_reduction_num_topics(self, num_topics):
current_num_topics = len(self.topic_vectors)
if num_topics >= current_num_topics:
raise ValueError(f"Number of topics must be less than {current_num_topics}.")
def _validate_num_docs(self, num_docs):
self._less_than_zero(num_docs, "num_docs")
document_count = len(self.doc_top)
if num_docs > document_count:
raise ValueError(f"num_docs cannot exceed the number of documents: {document_count}.")
def _validate_num_topics(self, num_topics, reduced):
self._less_than_zero(num_topics, "num_topics")
if reduced:
topic_count = len(self.topic_vectors_reduced)
if num_topics > topic_count:
raise ValueError(f"num_topics cannot exceed the number of reduced topics: {topic_count}.")
else:
topic_count = len(self.topic_vectors)
if num_topics > topic_count:
raise ValueError(f"num_topics cannot exceed the number of topics: {topic_count}.")
def _validate_topic_num(self, topic_num, reduced):
self._less_than_zero(topic_num, "topic_num")
if reduced:
topic_count = len(self.topic_vectors_reduced) - 1
if topic_num > topic_count:
raise ValueError(f"Invalid topic number: valid reduced topics numbers are 0 to {topic_count}.")
else:
topic_count = len(self.topic_vectors) - 1
if topic_num > topic_count:
raise ValueError(f"Invalid topic number: valid original topics numbers are 0 to {topic_count}.")
def _validate_topic_search(self, topic_num, num_docs, reduced):
self._less_than_zero(num_docs, "num_docs")
if reduced:
if num_docs > self.topic_sizes_reduced[topic_num]:
raise ValueError(f"Invalid number of documents: reduced topic {topic_num}"
f" only has {self.topic_sizes_reduced[topic_num]} documents.")
else:
if num_docs > self.topic_sizes[topic_num]:
raise ValueError(f"Invalid number of documents: original topic {topic_num}"
f" only has {self.topic_sizes[topic_num]} documents.")
def _validate_doc_ids(self, doc_ids, doc_ids_neg):
if not (isinstance(doc_ids, list) or isinstance(doc_ids, np.ndarray)):
raise ValueError("doc_ids must be a list of string or int.")
if not (isinstance(doc_ids_neg, list) or isinstance(doc_ids_neg, np.ndarray)):
raise ValueError("doc_ids_neg must be a list of string or int.")
if isinstance(doc_ids, np.ndarray):
doc_ids = list(doc_ids)
if isinstance(doc_ids_neg, np.ndarray):
doc_ids_neg = list(doc_ids_neg)
doc_ids_all = doc_ids + doc_ids_neg
if self.document_ids is not None:
for doc_id in doc_ids_all:
if doc_id not in self.doc_id2index:
raise ValueError(f"{doc_id} is not a valid document id.")
elif min(doc_ids) < 0:
raise ValueError(f"{min(doc_ids)} is not a valid document id.")
elif max(doc_ids) > len(self.doc_top) - 1:
raise ValueError(f"{max(doc_ids)} is not a valid document id.")
def _validate_keywords(self, keywords, keywords_neg):
if not (isinstance(keywords, list) or isinstance(keywords, np.ndarray)):
raise ValueError("keywords must be a list of strings.")
if not (isinstance(keywords_neg, list) or isinstance(keywords_neg, np.ndarray)):
raise ValueError("keywords_neg must be a list of strings.")
keywords_lower = [keyword.lower() for keyword in keywords]
keywords_neg_lower = [keyword.lower() for keyword in keywords_neg]
if self.embedding_model == 'doc2vec':
vocab = self.model.wv.vocab
else:
vocab = self.vocab
for word in keywords_lower + keywords_neg_lower:
if word not in vocab:
raise ValueError(f"'{word}' has not been learned by the model so it cannot be searched.")
return keywords_lower, keywords_neg_lower
def _validate_document_ids_add_doc(self, documents, document_ids):
if document_ids is None:
raise ValueError("Document ids need to be provided.")
if len(documents) != len(document_ids):
raise ValueError("Document ids need to match number of documents.")
if len(document_ids) != len(set(document_ids)):
raise ValueError("Document ids need to be unique.")
if len(set(document_ids).intersection(self.document_ids)) > 0:
raise ValueError("Some document ids already exist in model.")
if self.doc_id_type == np.str_:
if not all((isinstance(doc_id, str) or isinstance(doc_id, np.str_)) for doc_id in document_ids):
raise ValueError("Document ids need to be of type str.")
if self.doc_id_type == np.int_:
if not all((isinstance(doc_id, int) or isinstance(doc_id, np.int_)) for doc_id in document_ids):
raise ValueError("Document ids need to be of type int.")
@staticmethod
def _validate_documents(documents):
if not all((isinstance(doc, str) or isinstance(doc, np.str_)) for doc in documents):
raise ValueError("Documents need to be a list of strings.")
@staticmethod
def _validate_query(query):
if not isinstance(query, str) or isinstance(query, np.str_):
raise ValueError("Query needs to be a string.")
def _validate_vector(self, vector):
if not isinstance(vector, np.ndarray):
raise ValueError("Vector needs to be a numpy array.")
vec_size = self._get_document_vectors().shape[1]
if not vector.shape[0] == vec_size:
raise ValueError(f"Vector needs to be of {vec_size} dimensions.")
def index_document_vectors(self, ef_construction=200, M=64):
"""
Creates an index of the document vectors using hnswlib. This will
lead to faster search times for models with a large number of
documents.
For more information on hnswlib see: https://github.com/nmslib/hnswlib
Parameters
----------
ef_construction: int (Optional default 200)
This parameter controls the trade-off between index construction
time and index accuracy. Larger values will lead to greater
accuracy but will take longer to construct.
M: int (Optional default 64)
This parameter controls the trade-off between both index size as
well as construction time and accuracy. Larger values will lead to
greater accuracy but will result in a larger index as well as
longer construction time.
For more information on the parameters see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
"""
self._check_hnswlib_status()
document_vectors = self._get_document_vectors()
vec_dim = document_vectors.shape[1]
num_vecs = document_vectors.shape[0]
index_ids = list(range(0, len(self.document_ids)))
self.index_id2doc_id = dict(zip(index_ids, self.document_ids))
self.doc_id2index_id = dict(zip(self.document_ids, index_ids))
self.document_index = hnswlib.Index(space='ip', dim=vec_dim)
self.document_index.init_index(max_elements=num_vecs, ef_construction=ef_construction, M=M)
self.document_index.add_items(document_vectors, index_ids)
self.documents_indexed = True
def index_word_vectors(self, ef_construction=200, M=64):
"""
Creates an index of the word vectors using hnswlib. This will
lead to faster search times for models with a large number of
words.
For more information on hnswlib see: https://github.com/nmslib/hnswlib
Parameters
----------
ef_construction: int (Optional default 200)
This parameter controls the trade-off between index construction
time and index accuracy. Larger values will lead to greater
accuracy but will take longer to construct.
M: int (Optional default 64)
This parameter controls the trade-off between both index size as
well as construction time and accuracy. Larger values will lead to
greater accuracy but will result in a larger index as well as
longer construction time.
For more information on the parameters see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
"""
self._check_hnswlib_status()
word_vectors = self._get_word_vectors()
vec_dim = word_vectors.shape[1]
num_vecs = word_vectors.shape[0]
index_ids = list(range(0, num_vecs))
self.word_index = hnswlib.Index(space='ip', dim=vec_dim)
self.word_index.init_index(max_elements=num_vecs, ef_construction=ef_construction, M=M)
self.word_index.add_items(word_vectors, index_ids)
self.words_indexed = True
def update_embedding_model_path(self, embedding_model_path):
"""
Update the path of the embedding model to be loaded. The model will
no longer be downloaded but loaded from the path location.
Warning: the model at embedding_model_path must match the
embedding_model parameter type.
Parameters
----------
embedding_model_path: Str
Path to downloaded embedding model.
"""
self.embedding_model_path = embedding_model_path
def change_to_download_embedding_model(self):
"""
Use automatic download to load embedding model used for training.
Top2Vec will no longer try and load the embedding model from a file
if a embedding_model path was previously added.
"""
self.embedding_model_path = None
def get_documents_topics(self, doc_ids, reduced=False, num_topics=1):
"""
Get document topics.
The topic of each document will be returned.
The corresponding original topics are returned unless reduced=True,
in which case the reduced topics will be returned.
Parameters
----------
doc_ids: List of str, int
A unique value per document that is used for referring to
documents in search results. If ids were not given to the model,
the index of each document in the model is the id.
reduced: bool (Optional, default False)
Original topics are returned by default. If True the
reduced topics will be returned.
num_topics: int (Optional, default 1)
The number of topics to return per document.
Returns
-------
topic_nums: array of int, shape(len(doc_ids), num_topics)
The topic number(s) of the document corresponding to each doc_id.
topic_score: array of float, shape(len(doc_ids), num_topics)
Semantic similarity of document to topic(s). The cosine similarity
of the document and topic vector.
topics_words: array of shape(len(doc_ids), num_topics, 50)
For each topic the top 50 words are returned, in order
of semantic similarity to topic.
Example:
[['data', 'deep', 'learning' ... 'artificial'], <Topic 4>
['environment', 'warming', 'climate ... 'temperature'] <Topic 21>
...]
word_scores: array of shape(num_topics, 50)
For each topic the cosine similarity scores of the
top 50 words to the topic are returned.
Example:
[[0.7132, 0.6473, 0.5700 ... 0.3455], <Topic 4>
[0.7818', 0.7671, 0.7603 ... 0.6769] <Topic 21>
...]
"""
if reduced:
self._validate_hierarchical_reduction()
# make sure documents exist
self._validate_doc_ids(doc_ids, doc_ids_neg=[])
# get document indexes from ids
doc_indexes = self._get_document_indexes(doc_ids)
if num_topics == 1:
if reduced:
doc_topics = self.doc_top_reduced[doc_indexes]
doc_dist = self.doc_dist_reduced[doc_indexes]
topic_words = self.topic_words_reduced[doc_topics]
topic_word_scores = self.topic_word_scores_reduced[doc_topics]
else:
doc_topics = self.doc_top[doc_indexes]
doc_dist = self.doc_dist[doc_indexes]
topic_words = self.topic_words[doc_topics]
topic_word_scores = self.topic_word_scores[doc_topics]
else:
if reduced:
topic_vectors = self.topic_vectors_reduced
else:
topic_vectors = self.topic_vectors
doc_topics, doc_dist = self._calculate_documents_topic(topic_vectors,
self._get_document_vectors()[doc_indexes],
num_topics=num_topics)
topic_words = np.array([self.topic_words[topics] for topics in doc_topics])
topic_word_scores = np.array([self.topic_word_scores[topics] for topics in doc_topics])
return doc_topics, doc_dist, topic_words, topic_word_scores
def add_documents(self, documents, doc_ids=None, tokenizer=None, use_embedding_model_tokenizer=False):
"""
Update the model with new documents.
The documents will be added to the current model without changing
existing document, word and topic vectors. Topic sizes will be updated.
If adding a large quantity of documents relative to the current model
size, or documents containing a largely new vocabulary, a new model
should be trained for best results.
Parameters
----------
documents: List of str
doc_ids: List of str, int (Optional)
Only required when doc_ids were given to the original model.
A unique value per document that will be used for referring to
documents in search results.
tokenizer: callable (Optional, default None)
Override the default tokenization method. If None then
gensim.utils.simple_preprocess will be used.
use_embedding_model_tokenizer: bool (Optional, default False)
If using an embedding model other than doc2vec, use the model's
tokenizer for document embedding.
"""
# if tokenizer is not passed use default
if tokenizer is None:
tokenizer = default_tokenizer
# add documents
self._validate_documents(documents)
if self.documents is not None:
self.documents = np.append(self.documents, documents)
# add document ids
if self.document_ids_provided is True:
self._validate_document_ids_add_doc(documents, doc_ids)
doc_ids_len = len(self.document_ids)
self.document_ids = np.append(self.document_ids, doc_ids)
self.doc_id2index.update(dict(zip(doc_ids, list(range(doc_ids_len, doc_ids_len + len(doc_ids))))))
elif doc_ids is None:
num_docs = len(documents)
start_id = max(self.document_ids) + 1
doc_ids = list(range(start_id, start_id + num_docs))
doc_ids_len = len(self.document_ids)
self.document_ids = np.append(self.document_ids, doc_ids)
self.doc_id2index.update(dict(zip(doc_ids, list(range(doc_ids_len, doc_ids_len + len(doc_ids))))))
else:
raise ValueError("doc_ids cannot be used because they were not provided to model during training.")
if self.embedding_model == "doc2vec":
docs_processed = [tokenizer(doc) for doc in documents]
document_vectors = np.vstack([self.model.infer_vector(doc_words=doc,
alpha=0.025,
min_alpha=0.01,
epochs=100) for doc in docs_processed])
num_docs = len(documents)
self.model.docvecs.count += num_docs
self.model.docvecs.max_rawint += num_docs
self.model.docvecs.vectors_docs_norm = None
self._set_document_vectors(np.vstack([self._get_document_vectors(norm=False), document_vectors]))
self.model.docvecs.init_sims()
document_vectors = self._l2_normalize(document_vectors)
else:
if use_embedding_model_tokenizer:
docs_training = documents
else:
docs_processed = [tokenizer(doc) for doc in documents]
docs_training = [' '.join(doc) for doc in docs_processed]
document_vectors = self._embed_documents(docs_training)
self._set_document_vectors(np.vstack([self._get_document_vectors(), document_vectors]))
# update index
if self.documents_indexed:
# update capacity of index
current_max = self.document_index.get_max_elements()
updated_max = current_max + len(documents)
self.document_index.resize_index(updated_max)
# update index_id and doc_ids
start_index_id = max(self.index_id2doc_id.keys()) + 1
new_index_ids = list(range(start_index_id, start_index_id + len(doc_ids)))
self.index_id2doc_id.update(dict(zip(new_index_ids, doc_ids)))
self.doc_id2index_id.update(dict(zip(doc_ids, new_index_ids)))
self.document_index.add_items(document_vectors, new_index_ids)
# update topics
self._assign_documents_to_topic(document_vectors, hierarchy=False)
if self.hierarchy is not None:
self._assign_documents_to_topic(document_vectors, hierarchy=True)
def delete_documents(self, doc_ids):
"""
Delete documents from current model.
Warning: If document ids were not used in original model, deleting
documents will change the indexes and therefore doc_ids.
The documents will be deleted from the current model without changing
existing document, word and topic vectors. Topic sizes will be updated.
If deleting a large quantity of documents relative to the current model
size a new model should be trained for best results.
Parameters
----------
doc_ids: List of str, int
A unique value per document that is used for referring to documents
in search results.
"""
# make sure documents exist
self._validate_doc_ids(doc_ids, doc_ids_neg=[])
# update index
if self.documents_indexed:
# delete doc_ids from index
index_ids = [self.doc_id2index_id(doc_id) for doc_id in doc_ids]
for index_id in index_ids:
self.document_index.mark_deleted(index_id)
# update index_id and doc_ids
for doc_id in doc_ids:
self.doc_id2index_id.pop(doc_id)
for index_id in index_ids:
self.index_id2doc_id.pop(index_id)
# get document indexes from ids
doc_indexes = self._get_document_indexes(doc_ids)
# delete documents
if self.documents is not None:
self.documents = np.delete(self.documents, doc_indexes, 0)
# delete document ids
if self.document_ids is not None:
for doc_id in doc_ids:
self.doc_id2index.pop(doc_id)
keys = list(self.doc_id2index.keys())
self.document_ids = np.array(keys)
values = list(range(0, len(self.doc_id2index.values())))
self.doc_id2index = dict(zip(keys, values))
# delete document vectors
self._set_document_vectors(np.delete(self._get_document_vectors(norm=False), doc_indexes, 0))
if self.embedding_model == 'doc2vec':
num_docs = len(doc_indexes)
self.model.docvecs.count -= num_docs
self.model.docvecs.max_rawint -= num_docs
self.model.docvecs.vectors_docs_norm = None
self.model.docvecs.init_sims()
# update topics
self._unassign_documents_from_topic(doc_indexes, hierarchy=False)
if self.hierarchy is not None:
self._unassign_documents_from_topic(doc_indexes, hierarchy=True)
def get_num_topics(self, reduced=False):
"""
Get number of topics.
This is the number of topics Top2Vec has found in the data by default.
If reduced is True, the number of reduced topics is returned.
Parameters
----------
reduced: bool (Optional, default False)
The number of original topics will be returned by default. If True
will return the number of reduced topics, if hierarchical topic
reduction has been performed.
Returns
-------
num_topics: int
"""
if reduced:
self._validate_hierarchical_reduction()
return len(self.topic_vectors_reduced)
else:
return len(self.topic_vectors)
def get_topic_sizes(self, reduced=False):
"""
Get topic sizes.
The number of documents most similar to each topic. Topics are
in increasing order of size.
The sizes of the original topics is returned unless reduced=True,
in which case the sizes of the reduced topics will be returned.
Parameters
----------
reduced: bool (Optional, default False)
Original topic sizes are returned by default. If True the
reduced topic sizes will be returned.
Returns
-------
topic_sizes: array of int, shape(num_topics)
The number of documents most similar to the topic.
topic_nums: array of int, shape(num_topics)
The unique number of every topic will be returned.
"""
if reduced:
self._validate_hierarchical_reduction()
return np.array(self.topic_sizes_reduced.values), np.array(self.topic_sizes_reduced.index)
else:
return np.array(self.topic_sizes.values), np.array(self.topic_sizes.index)
def get_topics(self, num_topics=None, reduced=False):
"""
Get topics, ordered by decreasing size. All topics are returned
if num_topics is not specified.
The original topics found are returned unless reduced=True,
in which case reduced topics will be returned.
Each topic will consist of the top 50 semantically similar words
to the topic. These are the 50 words closest to topic vector
along with cosine similarity of each word from vector. The
higher the score the more relevant the word is to the topic.
Parameters
----------
num_topics: int, (Optional)
Number of topics to return.
reduced: bool (Optional, default False)
Original topics are returned by default. If True the
reduced topics will be returned.
Returns
-------
topics_words: array of shape(num_topics, 50)
For each topic the top 50 words are returned, in order
of semantic similarity to topic.
Example:
[['data', 'deep', 'learning' ... 'artificial'], <Topic 0>
['environment', 'warming', 'climate ... 'temperature'] <Topic 1>
...]
word_scores: array of shape(num_topics, 50)
For each topic the cosine similarity scores of the
top 50 words to the topic are returned.
Example:
[[0.7132, 0.6473, 0.5700 ... 0.3455], <Topic 0>
[0.7818', 0.7671, 0.7603 ... 0.6769] <Topic 1>
...]
topic_nums: array of int, shape(num_topics)
The unique number of every topic will be returned.
"""
if reduced:
self._validate_hierarchical_reduction()
if num_topics is None:
num_topics = len(self.topic_vectors_reduced)
else:
self._validate_num_topics(num_topics, reduced)
return self.topic_words_reduced[0:num_topics], self.topic_word_scores_reduced[0:num_topics], np.array(
range(0, num_topics))
else:
if num_topics is None:
num_topics = len(self.topic_vectors)
else:
self._validate_num_topics(num_topics, reduced)
return self.topic_words[0:num_topics], self.topic_word_scores[0:num_topics], np.array(range(0, num_topics))
def get_topic_hierarchy(self):
"""
Get the hierarchy of reduced topics. The mapping of each original topic
to the reduced topics is returned.
Hierarchical topic reduction must be performed before calling this
method.
Returns
-------
hierarchy: list of ints
Each index of the hierarchy corresponds to the topic number of a
reduced topic. For each reduced topic the topic numbers of the
original topics that were merged to create it are listed.
Example:
[[3] <Reduced Topic 0> contains original Topic 3
[2,4] <Reduced Topic 1> contains original Topics 2 and 4
[0,1] <Reduced Topic 3> contains original Topics 0 and 1
...]
"""
self._validate_hierarchical_reduction()
return self.hierarchy
def hierarchical_topic_reduction(self, num_topics):
"""
Reduce the number of topics discovered by Top2Vec.
The most representative topics of the corpus will be found, by
iteratively merging each smallest topic to the most similar topic until
num_topics is reached.
Parameters
----------
num_topics: int
The number of topics to reduce to.
Returns
-------
hierarchy: list of ints
Each index of hierarchy corresponds to the reduced topics, for each
reduced topic the indexes of the original topics that were merged
to create it are listed.
Example:
[[3] <Reduced Topic 0> contains original Topic 3
[2,4] <Reduced Topic 1> contains original Topics 2 and 4
[0,1] <Reduced Topic 3> contains original Topics 0 and 1
...]
"""
self._validate_hierarchical_reduction_num_topics(num_topics)
num_topics_current = self.topic_vectors.shape[0]
top_vecs = self.topic_vectors
top_sizes = [self.topic_sizes[i] for i in range(0, len(self.topic_sizes))]
hierarchy = [[i] for i in range(self.topic_vectors.shape[0])]
count = 0
interval = max(int(self._get_document_vectors().shape[0] / 50000), 1)
while num_topics_current > num_topics:
# find smallest and most similar topics
smallest = np.argmin(top_sizes)
res = np.inner(top_vecs[smallest], top_vecs)
sims = np.flip(np.argsort(res))
most_sim = sims[1]
if most_sim == smallest:
most_sim = sims[0]
# calculate combined topic vector
top_vec_smallest = top_vecs[smallest]
smallest_size = top_sizes[smallest]
top_vec_most_sim = top_vecs[most_sim]
most_sim_size = top_sizes[most_sim]
combined_vec = self._l2_normalize(((top_vec_smallest * smallest_size) +
(top_vec_most_sim * most_sim_size)) / (smallest_size + most_sim_size))
# update topic vectors
ix_keep = list(range(len(top_vecs)))
ix_keep.remove(smallest)
ix_keep.remove(most_sim)
top_vecs = top_vecs[ix_keep]
top_vecs = np.vstack([top_vecs, combined_vec])
num_topics_current = top_vecs.shape[0]
# update topics sizes
if count % interval == 0:
doc_top = self._calculate_documents_topic(topic_vectors=top_vecs,
document_vectors=self._get_document_vectors(),
dist=False)
topic_sizes = pd.Series(doc_top).value_counts()
top_sizes = [topic_sizes[i] for i in range(0, len(topic_sizes))]
else:
smallest_size = top_sizes.pop(smallest)
if most_sim < smallest:
most_sim_size = top_sizes.pop(most_sim)
else:
most_sim_size = top_sizes.pop(most_sim - 1)
combined_size = smallest_size + most_sim_size
top_sizes.append(combined_size)
count += 1
# update topic hierarchy
smallest_inds = hierarchy.pop(smallest)
if most_sim < smallest:
most_sim_inds = hierarchy.pop(most_sim)
else:
most_sim_inds = hierarchy.pop(most_sim - 1)
combined_inds = smallest_inds + most_sim_inds
hierarchy.append(combined_inds)
# re-calculate topic vectors from clusters
doc_top = self._calculate_documents_topic(topic_vectors=top_vecs,
document_vectors=self._get_document_vectors(),
dist=False)
self.topic_vectors_reduced = self._l2_normalize(np.vstack([self._get_document_vectors()
[np.where(doc_top == label)[0]]
.mean(axis=0) for label in set(doc_top)]))
self.hierarchy = hierarchy
# assign documents to topic
self.doc_top_reduced, self.doc_dist_reduced = self._calculate_documents_topic(self.topic_vectors_reduced,
self._get_document_vectors())
# find topic words and scores
self.topic_words_reduced, self.topic_word_scores_reduced = self._find_topic_words_and_scores(
topic_vectors=self.topic_vectors_reduced)
# calculate topic sizes
self.topic_sizes_reduced = self._calculate_topic_sizes(hierarchy=True)
# re-order topics
self._reorder_topics(hierarchy=True)
return self.hierarchy
def query_documents(self, query, num_docs, return_documents=True, use_index=False, ef=None, tokenizer=None):
"""
Semantic search of documents using a query.
The most semantically similar documents to the query will be returned.
Parameters
----------
query: string
Any sequence of text. This could be an actual question, a sentence,
a paragraph or a document.
num_docs: int
Number of documents to return.
return_documents: bool (Optional default True)
Determines if the documents will be returned. If they were not
saved in the model they will not be returned.
use_index: bool (Optional default False)
If index_documents method has been called, setting this to True
will speed up search for models with large number of documents.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
tokenizer: callable (Optional, default None)
** For doc2vec embedding model only **
Override the default tokenization method. If None then
gensim.utils.simple_preprocess will be used.
Returns
-------
documents: (Optional) array of str, shape(num_docs)
The documents in a list, the most similar are first.
Will only be returned if the documents were saved and if
return_documents is set to True.
doc_scores: array of float, shape(num_docs)
Semantic similarity of document to vector. The cosine similarity of
the document and vector.
doc_ids: array of int, shape(num_docs)
Unique ids of documents. If ids were not given to the model, the
index of the document in the model will be returned.
"""
self._validate_query(query)
self._validate_num_docs(num_docs)
if self.embedding_model != "doc2vec":
query_vec = self._embed_query(query)
else:
# if tokenizer is not passed use default
if tokenizer is None:
tokenizer = default_tokenizer
tokenized_query = tokenizer(query)
query_vec = self.model.infer_vector(doc_words=tokenized_query,
alpha=0.025,
min_alpha=0.01,
epochs=100)
return self.search_documents_by_vector(query_vec, num_docs, return_documents=return_documents,
use_index=use_index, ef=ef)
def query_topics(self, query, num_topics, reduced=False, tokenizer=None):
"""
Semantic search of topics using keywords.
These are the topics closest to the vector. Topics are ordered by
proximity to the vector. Successive topics in the list are less
semantically similar to the vector.
Parameters
----------
query: string
Any sequence of text. This could be an actual question, a sentence,
a paragraph or a document.
num_topics: int
Number of documents to return.
reduced: bool (Optional, default False)
Original topics are searched by default. If True the
reduced topics will be searched.
tokenizer: callable (Optional, default None)
** For doc2vec embedding model only **
Override the default tokenization method. If None then
gensim.utils.simple_preprocess will be used.
Returns
-------
topics_words: array of shape (num_topics, 50)
For each topic the top 50 words are returned, in order of semantic
similarity to topic.
Example:
[['data', 'deep', 'learning' ... 'artificial'], <Topic 0>
['environment', 'warming', 'climate ... 'temperature'] <Topic 1>
...]
word_scores: array of shape (num_topics, 50)
For each topic the cosine similarity scores of the top 50 words
to the topic are returned.
Example:
[[0.7132, 0.6473, 0.5700 ... 0.3455], <Topic 0>
[0.7818', 0.7671, 0.7603 ... 0.6769] <Topic 1>
...]
topic_scores: array of float, shape(num_topics)
For each topic the cosine similarity to the search keywords will be
returned.
topic_nums: array of int, shape(num_topics)
The unique number of every topic will be returned.
"""
self._validate_query(query)
if self.embedding_model != "doc2vec":
query_vec = self._embed_query(query)
else:
# if tokenizer is not passed use default
if tokenizer is None:
tokenizer = default_tokenizer
tokenized_query = tokenizer(query)
query_vec = self.model.infer_vector(doc_words=tokenized_query,
alpha=0.025,
min_alpha=0.01,
epochs=100)
return self.search_topics_by_vector(query_vec, num_topics=num_topics, reduced=reduced)
def search_documents_by_vector(self, vector, num_docs, return_documents=True, use_index=False, ef=None):
"""
Semantic search of documents using a vector.
These are the documents closest to the vector. Documents are
ordered by proximity to the vector. Successive documents in the
list are less semantically similar to the vector.
Parameters
----------
vector: array of shape(vector dimension, 1)
The vector dimension should be the same as the vectors in
the topic_vectors variable. (i.e. model.topic_vectors.shape[1])
num_docs: int
Number of documents to return.
return_documents: bool (Optional default True)
Determines if the documents will be returned. If they were not
saved in the model they will not be returned.
use_index: bool (Optional default False)
If index_documents method has been called, setting this to True
will speed up search for models with large number of documents.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
Returns
-------
documents: (Optional) array of str, shape(num_docs)
The documents in a list, the most similar are first.
Will only be returned if the documents were saved and if
return_documents is set to True.
doc_scores: array of float, shape(num_docs)
Semantic similarity of document to vector. The cosine similarity of
the document and vector.
doc_ids: array of int, shape(num_docs)
Unique ids of documents. If ids were not given to the model, the
index of the document in the model will be returned.
"""
self._validate_vector(vector)
self._validate_num_docs(num_docs)
vector = self._l2_normalize(vector)
if use_index:
self._check_document_index_status()
if ef is not None:
self.document_index.set_ef(ef)
else:
self.document_index.set_ef(num_docs)
index_ids, doc_scores = self.document_index.knn_query(vector, k=num_docs)
index_ids = index_ids[0]
doc_ids = np.array([self.index_id2doc_id[index_id] for index_id in index_ids])
doc_scores = doc_scores[0]
doc_scores = np.array([1 - score for score in doc_scores])
doc_indexes = self._get_document_indexes(doc_ids)
else:
doc_indexes, doc_scores = self._search_vectors_by_vector(self._get_document_vectors(),
vector, num_docs)
doc_ids = self._get_document_ids(doc_indexes)
if self.documents is not None and return_documents:
documents = self.documents[doc_indexes]
return documents, doc_scores, doc_ids
else:
return doc_scores, doc_ids
def search_words_by_vector(self, vector, num_words, use_index=False, ef=None):
"""
Semantic search of words using a vector.
These are the words closest to the vector. Words are ordered by
proximity to the vector. Successive words in the list are less
semantically similar to the vector.
Parameters
----------
vector: array of shape(vector dimension, 1)
The vector dimension should be the same as the vectors in
the topic_vectors variable. (i.e. model.topic_vectors.shape[1])
num_words: int
Number of words to return.
use_index: bool (Optional default False)
If index_words method has been called, setting this to True will
speed up search for models with large number of words.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
Returns
-------
words: array of str, shape(num_words)
The words in a list, the most similar are first.
word_scores: array of float, shape(num_words)
Semantic similarity of word to vector. The cosine similarity of
the word and vector.
"""
self._validate_vector(vector)
vector = self._l2_normalize(vector)
if use_index:
self._check_word_index_status()
if ef is not None:
self.word_index.set_ef(ef)
else:
self.word_index.set_ef(num_words)
word_indexes, word_scores = self.word_index.knn_query(vector, k=num_words)
word_indexes = word_indexes[0]
word_scores = word_scores[0]
word_scores = np.array([1 - score for score in word_scores])
else:
word_indexes, word_scores = self._search_vectors_by_vector(self._get_word_vectors(),
vector, num_words)
words = np.array([self._index2word(index) for index in word_indexes])
return words, word_scores
def search_topics_by_vector(self, vector, num_topics, reduced=False):
"""
Semantic search of topics using keywords.
These are the topics closest to the vector. Topics are ordered by
proximity to the vector. Successive topics in the list are less
semantically similar to the vector.
Parameters
----------
vector: array of shape(vector dimension, 1)
The vector dimension should be the same as the vectors in
the topic_vectors variable. (i.e. model.topic_vectors.shape[1])
num_topics: int
Number of documents to return.
reduced: bool (Optional, default False)
Original topics are searched by default. If True the
reduced topics will be searched.
Returns
-------
topics_words: array of shape (num_topics, 50)
For each topic the top 50 words are returned, in order of semantic
similarity to topic.
Example:
[['data', 'deep', 'learning' ... 'artificial'], <Topic 0>
['environment', 'warming', 'climate ... 'temperature'] <Topic 1>
...]
word_scores: array of shape (num_topics, 50)
For each topic the cosine similarity scores of the top 50 words
to the topic are returned.
Example:
[[0.7132, 0.6473, 0.5700 ... 0.3455], <Topic 0>
[0.7818', 0.7671, 0.7603 ... 0.6769] <Topic 1>
...]
topic_scores: array of float, shape(num_topics)
For each topic the cosine similarity to the search keywords will be
returned.
topic_nums: array of int, shape(num_topics)
The unique number of every topic will be returned.
"""
self._validate_vector(vector)
self._validate_num_topics(num_topics, reduced)
vector = self._l2_normalize(vector)
if reduced:
self._validate_hierarchical_reduction()
topic_nums, topic_scores = self._search_vectors_by_vector(self.topic_vectors_reduced,
vector, num_topics)
topic_words = [self.topic_words_reduced[topic] for topic in topic_nums]
word_scores = [self.topic_word_scores_reduced[topic] for topic in topic_nums]
else:
topic_nums, topic_scores = self._search_vectors_by_vector(self.topic_vectors,
vector, num_topics)
topic_words = [self.topic_words[topic] for topic in topic_nums]
word_scores = [self.topic_word_scores[topic] for topic in topic_nums]
return topic_words, word_scores, topic_scores, topic_nums
def search_documents_by_topic(self, topic_num, num_docs, return_documents=True, reduced=False):
"""
Get the most semantically similar documents to the topic.
These are the documents closest to the topic vector. Documents are
ordered by proximity to the topic vector. Successive documents in the
list are less semantically similar to the topic.
Parameters
----------
topic_num: int
The topic number to search.
num_docs: int
Number of documents to return.
return_documents: bool (Optional default True)
Determines if the documents will be returned. If they were not
saved in the model they will not be returned.
reduced: bool (Optional, default False)
Original topics are used to search by default. If True the
reduced topics will be used.
Returns
-------
documents: (Optional) array of str, shape(num_docs)
The documents in a list, the most similar are first.
Will only be returned if the documents were saved and if
return_documents is set to True.
doc_scores: array of float, shape(num_docs)
Semantic similarity of document to topic. The cosine similarity of
the document and topic vector.
doc_ids: array of int, shape(num_docs)
Unique ids of documents. If ids were not given to the model, the
index of the document in the model will be returned.
"""
if reduced:
self._validate_hierarchical_reduction()
self._validate_topic_num(topic_num, reduced)
self._validate_topic_search(topic_num, num_docs, reduced)
topic_document_indexes = np.where(self.doc_top_reduced == topic_num)[0]
topic_document_indexes_ordered = np.flip(np.argsort(self.doc_dist_reduced[topic_document_indexes]))
doc_indexes = topic_document_indexes[topic_document_indexes_ordered][0:num_docs]
doc_scores = self.doc_dist_reduced[doc_indexes]
doc_ids = self._get_document_ids(doc_indexes)
else:
self._validate_topic_num(topic_num, reduced)
self._validate_topic_search(topic_num, num_docs, reduced)
topic_document_indexes = np.where(self.doc_top == topic_num)[0]
topic_document_indexes_ordered = np.flip(np.argsort(self.doc_dist[topic_document_indexes]))
doc_indexes = topic_document_indexes[topic_document_indexes_ordered][0:num_docs]
doc_scores = self.doc_dist[doc_indexes]
doc_ids = self._get_document_ids(doc_indexes)
if self.documents is not None and return_documents:
documents = self.documents[doc_indexes]
return documents, doc_scores, doc_ids
else:
return doc_scores, doc_ids
def search_documents_by_keywords(self, keywords, num_docs, keywords_neg=None, return_documents=True,
use_index=False, ef=None):
"""
Semantic search of documents using keywords.
The most semantically similar documents to the combination of the
keywords will be returned. If negative keywords are provided, the
documents will be semantically dissimilar to those words. Too many
keywords or certain combinations of words may give strange results.
This method finds an average vector(negative keywords are subtracted)
of all the keyword vectors and returns the documents closest to the
resulting vector.
Parameters
----------
keywords: List of str
List of positive keywords being used for search of semantically
similar documents.
keywords_neg: List of str (Optional)
List of negative keywords being used for search of semantically
dissimilar documents.
num_docs: int
Number of documents to return.
return_documents: bool (Optional default True)
Determines if the documents will be returned. If they were not
saved in the model they will also not be returned.
use_index: bool (Optional default False)
If index_documents method has been called, setting this to True
will speed up search for models with large number of documents.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
Returns
-------
documents: (Optional) array of str, shape(num_docs)
The documents in a list, the most similar are first.
Will only be returned if the documents were saved and if
return_documents is set to True.
doc_scores: array of float, shape(num_docs)
Semantic similarity of document to keywords. The cosine similarity
of the document and average of keyword vectors.
doc_ids: array of int, shape(num_docs)
Unique ids of documents. If ids were not given to the model, the
index of the document in the model will be returned.
"""
if keywords_neg is None:
keywords_neg = []
self._validate_num_docs(num_docs)
keywords, keywords_neg = self._validate_keywords(keywords, keywords_neg)
word_vecs = self._words2word_vectors(keywords)
neg_word_vecs = self._words2word_vectors(keywords_neg)
if use_index:
self._check_document_index_status()
combined_vector = self._get_combined_vec(word_vecs, neg_word_vecs)
return self.search_documents_by_vector(combined_vector, num_docs, return_documents=return_documents,
use_index=True, ef=ef)
if self.embedding_model == 'doc2vec':
sim_docs = self.model.docvecs.most_similar(positive=word_vecs,
negative=neg_word_vecs,
topn=num_docs)
doc_indexes = [doc[0] for doc in sim_docs]
doc_scores = np.array([doc[1] for doc in sim_docs])
else:
combined_vector = self._get_combined_vec(word_vecs, neg_word_vecs)
doc_indexes, doc_scores = self._search_vectors_by_vector(self._get_document_vectors(),
combined_vector, num_docs)
doc_ids = self._get_document_ids(doc_indexes)
if self.documents is not None and return_documents:
documents = self.documents[doc_indexes]
return documents, doc_scores, doc_ids
else:
return doc_scores, doc_ids
def similar_words(self, keywords, num_words, keywords_neg=None, use_index=False, ef=None):
"""
Semantic similarity search of words.
The most semantically similar word to the combination of the keywords
will be returned. If negative keywords are provided, the words will be
semantically dissimilar to those words. Too many keywords or certain
combinations of words may give strange results. This method finds an
average vector(negative keywords are subtracted) of all the keyword
vectors and returns the words closest to the resulting vector.
Parameters
----------
keywords: List of str
List of positive keywords being used for search of semantically
similar words.
keywords_neg: List of str
List of negative keywords being used for search of semantically
dissimilar words.
num_words: int
Number of words to return.
use_index: bool (Optional default False)
If index_words method has been called, setting this to True will
speed up search for models with large number of words.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
Returns
-------
words: array of str, shape(num_words)
The words in a list, the most similar are first.
word_scores: array of float, shape(num_words)
Semantic similarity of word to keywords. The cosine similarity of
the word and average of keyword vectors.
"""
if keywords_neg is None:
keywords_neg = []
keywords, keywords_neg = self._validate_keywords(keywords, keywords_neg)
word_vecs = self._words2word_vectors(keywords)
neg_word_vecs = self._words2word_vectors(keywords_neg)
combined_vector = self._get_combined_vec(word_vecs, neg_word_vecs)
num_res = min(num_words + len(keywords) + len(keywords_neg), self._get_word_vectors().shape[0])
# if use_index:
words, word_scores = self.search_words_by_vector(vector=combined_vector,
num_words=num_res,
use_index=use_index,
ef=ef)
res_indexes = [index for index, word in enumerate(words)
if word not in list(keywords) + list(keywords_neg)][:num_words]
words = words[res_indexes]
word_scores = word_scores[res_indexes]
return words, word_scores
def search_topics(self, keywords, num_topics, keywords_neg=None, reduced=False):
"""
Semantic search of topics using keywords.
The most semantically similar topics to the combination of the keywords
will be returned. If negative keywords are provided, the topics will be
semantically dissimilar to those words. Topics will be ordered by
decreasing similarity to the keywords. Too many keywords or certain
combinations of words may give strange results. This method finds an
average vector(negative keywords are subtracted) of all the keyword
vectors and returns the topics closest to the resulting vector.
Parameters
----------
keywords: List of str
List of positive keywords being used for search of semantically
similar documents.
keywords_neg: (Optional) List of str
List of negative keywords being used for search of semantically
dissimilar documents.
num_topics: int
Number of documents to return.
reduced: bool (Optional, default False)
Original topics are searched by default. If True the
reduced topics will be searched.
Returns
-------
topics_words: array of shape (num_topics, 50)
For each topic the top 50 words are returned, in order of semantic
similarity to topic.
Example:
[['data', 'deep', 'learning' ... 'artificial'], <Topic 0>
['environment', 'warming', 'climate ... 'temperature'] <Topic 1>
...]
word_scores: array of shape (num_topics, 50)
For each topic the cosine similarity scores of the top 50 words
to the topic are returned.
Example:
[[0.7132, 0.6473, 0.5700 ... 0.3455], <Topic 0>
[0.7818', 0.7671, 0.7603 ... 0.6769] <Topic 1>
...]
topic_scores: array of float, shape(num_topics)
For each topic the cosine similarity to the search keywords will be
returned.
topic_nums: array of int, shape(num_topics)
The unique number of every topic will be returned.
"""
if keywords_neg is None:
keywords_neg = []
keywords, keywords_neg = self._validate_keywords(keywords, keywords_neg)
word_vecs = self._words2word_vectors(keywords)
neg_word_vecs = self._words2word_vectors(keywords_neg)
combined_vector = self._get_combined_vec(word_vecs, neg_word_vecs)
return self.search_topics_by_vector(combined_vector, num_topics=num_topics, reduced=reduced)
def search_documents_by_documents(self, doc_ids, num_docs, doc_ids_neg=None, return_documents=True,
use_index=False, ef=None):
"""
Semantic similarity search of documents.
The most semantically similar documents to the semantic combination of
document ids provided will be returned. If negative document ids are
provided, the documents will be semantically dissimilar to those
document ids. Documents will be ordered by decreasing similarity. This
method finds the closest document vectors to the provided documents
averaged.
Parameters
----------
doc_ids: List of int, str
Unique ids of document. If ids were not given, the index of
document in the original corpus.
doc_ids_neg: (Optional) List of int, str
Unique ids of document. If ids were not given, the index of
document in the original corpus.
num_docs: int
Number of documents to return.
return_documents: bool (Optional default True)
Determines if the documents will be returned. If they were not
saved in the model they will also not be returned.
use_index: bool (Optional default False)
If index_documents method has been called, setting this to True
will speed up search for models with large number of documents.
ef: int (Optional default None)
Higher ef leads to more accurate but slower search. This value
must be higher than num_docs.
For more information see:
https://github.com/nmslib/hnswlib/blob/master/ALGO_PARAMS.md
Returns
-------
documents: (Optional) array of str, shape(num_docs)
The documents in a list, the most similar are first.
Will only be returned if the documents were saved and if
return_documents is set to True.
doc_scores: array of float, shape(num_docs)
Semantic similarity of document to keywords. The cosine similarity
of the document and average of keyword vectors.
doc_ids: array of int, shape(num_docs)
Unique ids of documents. If ids were not given to the model, the
index of the document in the model will be returned.
"""
if doc_ids_neg is None:
doc_ids_neg = []
self._validate_num_docs(num_docs)
self._validate_doc_ids(doc_ids, doc_ids_neg)
doc_indexes = self._get_document_indexes(doc_ids)
doc_indexes_neg = self._get_document_indexes(doc_ids_neg)
if use_index:
self._check_document_index_status()
document_vectors = self._get_document_vectors()
doc_vecs = [document_vectors[ind] for ind in doc_indexes]
doc_vecs_neg = [document_vectors[ind] for ind in doc_indexes_neg]
combined_vector = self._get_combined_vec(doc_vecs, doc_vecs_neg)
return self.search_documents_by_vector(combined_vector, num_docs, return_documents=return_documents,
use_index=True, ef=ef)
if self.embedding_model == 'doc2vec':
sim_docs = self.model.docvecs.most_similar(positive=doc_indexes,
negative=doc_indexes_neg,
topn=num_docs)
doc_indexes = [doc[0] for doc in sim_docs]
doc_scores = np.array([doc[1] for doc in sim_docs])
else:
doc_vecs = [self.document_vectors[ind] for ind in doc_indexes]
doc_vecs_neg = [self.document_vectors[ind] for ind in doc_indexes_neg]
combined_vector = self._get_combined_vec(doc_vecs, doc_vecs_neg)
num_res = min(num_docs + len(doc_indexes) + len(doc_indexes_neg),
self._get_document_vectors().shape[0])
# don't return documents that were searched
search_doc_indexes = list(doc_indexes) + list(doc_indexes_neg)
doc_indexes, doc_scores = self._search_vectors_by_vector(self._get_document_vectors(),
combined_vector, num_res)
res_indexes = [index for index, doc_ind in enumerate(doc_indexes)
if doc_ind not in search_doc_indexes][:num_docs]
doc_indexes = doc_indexes[res_indexes]
doc_scores = doc_scores[res_indexes]
doc_ids = self._get_document_ids(doc_indexes)
if self.documents is not None and return_documents:
documents = self.documents[doc_indexes]
return documents, doc_scores, doc_ids
else:
return doc_scores, doc_ids
def generate_topic_wordcloud(self, topic_num, background_color="black", reduced=False):
"""
Create a word cloud for a topic.
A word cloud will be generated and displayed. The most semantically
similar words to the topic will have the largest size, less similar
words will be smaller. The size is determined using the cosine distance
of the word vectors from the topic vector.
Parameters
----------
topic_num: int
The topic number to search.
background_color : str (Optional, default='white')
Background color for the word cloud image. Suggested options are:
* white
* black
reduced: bool (Optional, default False)
Original topics are used by default. If True the
reduced topics will be used.
Returns
-------
A matplotlib plot of the word cloud with the topic number will be
displayed.
"""
if reduced:
self._validate_hierarchical_reduction()
self._validate_topic_num(topic_num, reduced)
word_score_dict = dict(zip(self.topic_words_reduced[topic_num],
softmax(self.topic_word_scores_reduced[topic_num])))
else:
self._validate_topic_num(topic_num, reduced)
word_score_dict = dict(zip(self.topic_words[topic_num],
softmax(self.topic_word_scores[topic_num])))
plt.figure(figsize=(16, 4),
dpi=200)
plt.axis("off")
plt.imshow(
WordCloud(width=1600,
height=400,
background_color=background_color).generate_from_frequencies(word_score_dict))
plt.title("Topic " + str(topic_num), loc='left', fontsize=25, pad=20)