bertrend 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bertrend/BERTopicModel.py +320 -0
- bertrend/BERTrend.py +1211 -0
- bertrend/__init__.py +59 -0
- bertrend/config/bertopic_default_config.toml +48 -0
- bertrend/config/bertrend_default_config.toml +16 -0
- bertrend/config/parameters.py +92 -0
- bertrend/config/services_default_config.toml +32 -0
- bertrend/demos/__init__.py +4 -0
- bertrend/demos/demos_utils/__init__.py +13 -0
- bertrend/demos/demos_utils/data_loading_component.py +267 -0
- bertrend/demos/demos_utils/embed_documents_component.py +52 -0
- bertrend/demos/demos_utils/icons.py +41 -0
- bertrend/demos/demos_utils/messages.py +11 -0
- bertrend/demos/demos_utils/parameters_component.py +162 -0
- bertrend/demos/demos_utils/state_utils.py +99 -0
- bertrend/demos/start_summarizer_app.sh +9 -0
- bertrend/demos/start_topic_analysis_app.sh +9 -0
- bertrend/demos/start_weak_signals_app.sh +9 -0
- bertrend/demos/summarization/__init__.py +4 -0
- bertrend/demos/summarization/summarizer_app.py +98 -0
- bertrend/demos/topic_analysis/__init__.py +4 -0
- bertrend/demos/topic_analysis/app.py +87 -0
- bertrend/demos/topic_analysis/app_utils.py +69 -0
- bertrend/demos/topic_analysis/demo_pages/__init__.py +4 -0
- bertrend/demos/topic_analysis/demo_pages/explore_topics.py +455 -0
- bertrend/demos/topic_analysis/demo_pages/newsletters_generation.py +178 -0
- bertrend/demos/topic_analysis/demo_pages/temporal_visualizations.py +573 -0
- bertrend/demos/topic_analysis/demo_pages/topic_visualizations.py +280 -0
- bertrend/demos/topic_analysis/demo_pages/training_page.py +219 -0
- bertrend/demos/topic_analysis/messages.py +13 -0
- bertrend/demos/weak_signals/__init__.py +4 -0
- bertrend/demos/weak_signals/app.py +510 -0
- bertrend/demos/weak_signals/messages.py +32 -0
- bertrend/demos/weak_signals/visualizations_utils.py +403 -0
- bertrend/llm_utils/__init__.py +4 -0
- bertrend/llm_utils/newsletter.css +131 -0
- bertrend/llm_utils/newsletter_features.py +272 -0
- bertrend/llm_utils/openai_client.py +235 -0
- bertrend/llm_utils/prompts.py +94 -0
- bertrend/metrics/__init__.py +4 -0
- bertrend/metrics/temporal_metrics_embedding.py +861 -0
- bertrend/metrics/topic_metrics.py +199 -0
- bertrend/resources/common_ngrams.json +23 -0
- bertrend/resources/stopwords-en.json +3 -0
- bertrend/resources/stopwords-fr.json +1 -0
- bertrend/resources/stopwords-rte.json +15 -0
- bertrend/services/__init__.py +4 -0
- bertrend/services/authentication.py +66 -0
- bertrend/services/embedding_client.py +154 -0
- bertrend/services/embedding_service.py +380 -0
- bertrend/services/summarizer.py +45 -0
- bertrend/services/summary/__init__.py +4 -0
- bertrend/services/summary/abstractive_summarizer.py +74 -0
- bertrend/services/summary/chatgpt_summarizer.py +67 -0
- bertrend/services/summary/extractive_summarizer.py +368 -0
- bertrend/services/summary/lexrank.py +126 -0
- bertrend/services/summary/prompts.py +43 -0
- bertrend/tests/__init__.py +4 -0
- bertrend/tests/test_abstractive_summarizer.py +140 -0
- bertrend/tests/test_bertrend.py +1091 -0
- bertrend/tests/test_cache_utils.py +111 -0
- bertrend/tests/test_chatgpt_summarizer.py +150 -0
- bertrend/tests/test_config_utils.py +54 -0
- bertrend/tests/test_data_loading.py +326 -0
- bertrend/tests/test_data_structure.py +53 -0
- bertrend/tests/test_embedding_client.py +546 -0
- bertrend/tests/test_extractive_summarizer.py +250 -0
- bertrend/tests/test_installation.py +183 -0
- bertrend/tests/test_newsletter_features.py +354 -0
- bertrend/tests/test_openai_client.py +210 -0
- bertrend/tests/test_representative_docs.py +125 -0
- bertrend/tests/test_summarizer.py +115 -0
- bertrend/tests/test_temporal_metrics_embedding.py +349 -0
- bertrend/tests/test_topic_description.py +159 -0
- bertrend/tests/test_topic_metrics.py +233 -0
- bertrend/tests/test_topic_model.py +285 -0
- bertrend/tests/test_trend_analysis_data_structure.py +174 -0
- bertrend/tests/test_trend_analysis_prompts.py +225 -0
- bertrend/tests/test_trend_analysis_visualizations.py +307 -0
- bertrend/tests/test_trend_analysis_weak_signals.py +598 -0
- bertrend/tests/test_utils_init.py +70 -0
- bertrend/tests/test_visualizations.py +306 -0
- bertrend/topic_analysis/__init__.py +4 -0
- bertrend/topic_analysis/data_structure.py +13 -0
- bertrend/topic_analysis/prompts.py +42 -0
- bertrend/topic_analysis/representative_docs.py +57 -0
- bertrend/topic_analysis/topic_description.py +66 -0
- bertrend/topic_analysis/visualizations.py +315 -0
- bertrend/trend_analysis/__init__.py +4 -0
- bertrend/trend_analysis/data_structure.py +78 -0
- bertrend/trend_analysis/prompts.py +229 -0
- bertrend/trend_analysis/signal_llm_template_en.html +181 -0
- bertrend/trend_analysis/signal_llm_template_fr.html +181 -0
- bertrend/trend_analysis/visualizations.py +605 -0
- bertrend/trend_analysis/weak_signals.py +425 -0
- bertrend/utils/__init__.py +31 -0
- bertrend/utils/cache_utils.py +31 -0
- bertrend/utils/config_utils.py +41 -0
- bertrend/utils/data_loading.py +329 -0
- bertrend-0.3.1.dist-info/AUTHORS.txt +15 -0
- bertrend-0.3.1.dist-info/LICENSE.md +373 -0
- bertrend-0.3.1.dist-info/METADATA +400 -0
- bertrend-0.3.1.dist-info/RECORD +181 -0
- bertrend-0.3.1.dist-info/WHEEL +4 -0
- bertrend_apps/__init__.py +4 -0
- bertrend_apps/common/__init__.py +4 -0
- bertrend_apps/common/crontab_utils.py +113 -0
- bertrend_apps/common/mail_utils.py +91 -0
- bertrend_apps/config/feeds/arxiv_feed.toml +15 -0
- bertrend_apps/config/feeds/decarbonation_feed.toml +15 -0
- bertrend_apps/config/feeds/llm_en_feed.toml +15 -0
- bertrend_apps/config/feeds/llm_feed.toml +16 -0
- bertrend_apps/config/feeds/nlp_feed.toml +15 -0
- bertrend_apps/config/feeds/rte_feed.toml +15 -0
- bertrend_apps/config/gmail_credentials.json +1 -0
- bertrend_apps/config/newsletters/arxiv_newsletter_from_last.toml +39 -0
- bertrend_apps/config/newsletters/arxiv_newsletter_from_scratch.toml +39 -0
- bertrend_apps/config/newsletters/decarbonation_newsletter_from_last.toml +38 -0
- bertrend_apps/config/newsletters/decarbonation_newsletter_from_scratch.toml +38 -0
- bertrend_apps/config/newsletters/llm_en_newsletter_from_last.toml +38 -0
- bertrend_apps/config/newsletters/llm_newsletter_from_last.toml +39 -0
- bertrend_apps/config/newsletters/nlp_newsletter_from_last.toml +38 -0
- bertrend_apps/config/newsletters/rte_newsletter_from_last.toml +38 -0
- bertrend_apps/config/newsletters/rte_newsletter_from_scratch.toml +38 -0
- bertrend_apps/data_provider/__init__.py +9 -0
- bertrend_apps/data_provider/__main__.py +237 -0
- bertrend_apps/data_provider/arxiv_provider.py +161 -0
- bertrend_apps/data_provider/bing_news_provider.py +82 -0
- bertrend_apps/data_provider/curebot_provider.py +105 -0
- bertrend_apps/data_provider/data_provider.py +194 -0
- bertrend_apps/data_provider/google_news_provider.py +94 -0
- bertrend_apps/data_provider/newscatcher_provider.py +74 -0
- bertrend_apps/data_provider/utils.py +165 -0
- bertrend_apps/exploration/__init__.py +4 -0
- bertrend_apps/exploration/curebot/__init__.py +4 -0
- bertrend_apps/exploration/curebot/app.py +45 -0
- bertrend_apps/exploration/curebot/app_utils.py +317 -0
- bertrend_apps/exploration/curebot/config.toml +33 -0
- bertrend_apps/exploration/curebot/import_extraction_article.ipynb +108 -0
- bertrend_apps/exploration/curebot/old_veille_analyse.py +298 -0
- bertrend_apps/exploration/curebot/prompts.py +24 -0
- bertrend_apps/exploration/curebot/start_newsletter_generator.sh +8 -0
- bertrend_apps/exploration/curebot/tabs/tab1.py +150 -0
- bertrend_apps/exploration/curebot/tabs/tab2.py +95 -0
- bertrend_apps/exploration/curebot/tabs/tab3.py +125 -0
- bertrend_apps/exploration/dataviz/Main_page.py +10 -0
- bertrend_apps/exploration/dataviz/__init__.py +4 -0
- bertrend_apps/exploration/dataviz/pages/1_Data_feeds.py +105 -0
- bertrend_apps/exploration/geolocalization/__init__.py +4 -0
- bertrend_apps/exploration/geolocalization/entity_utils.py +118 -0
- bertrend_apps/exploration/geolocalization/spacy/__init__.py +4 -0
- bertrend_apps/exploration/geolocalization/spacy/common_factory.py +142 -0
- bertrend_apps/exploration/geolocalization/spacy/dbpedia.py +206 -0
- bertrend_apps/exploration/geolocalization/spacy/nlp.config +37 -0
- bertrend_apps/exploration/geolocalization/spacy/utils.py +39 -0
- bertrend_apps/install_feeds_and_newsletters.py +29 -0
- bertrend_apps/newsletters/__init__.py +4 -0
- bertrend_apps/newsletters/__main__.py +277 -0
- bertrend_apps/prospective_demo/.streamlit/config.toml +3 -0
- bertrend_apps/prospective_demo/.streamlit/secrets.toml +9 -0
- bertrend_apps/prospective_demo/__init__.py +70 -0
- bertrend_apps/prospective_demo/app.py +111 -0
- bertrend_apps/prospective_demo/authentication.py +44 -0
- bertrend_apps/prospective_demo/dashboard_analysis.py +132 -0
- bertrend_apps/prospective_demo/dashboard_common.py +79 -0
- bertrend_apps/prospective_demo/dashboard_signals.py +226 -0
- bertrend_apps/prospective_demo/feeds_common.py +38 -0
- bertrend_apps/prospective_demo/feeds_config.py +261 -0
- bertrend_apps/prospective_demo/feeds_data.py +95 -0
- bertrend_apps/prospective_demo/i18n.py +136 -0
- bertrend_apps/prospective_demo/llm_utils.py +34 -0
- bertrend_apps/prospective_demo/models_info.py +361 -0
- bertrend_apps/prospective_demo/perf_utils.py +23 -0
- bertrend_apps/prospective_demo/process_new_data.py +348 -0
- bertrend_apps/prospective_demo/report_generation.py +214 -0
- bertrend_apps/prospective_demo/report_template.html +148 -0
- bertrend_apps/prospective_demo/report_utils.py +132 -0
- bertrend_apps/prospective_demo/start_demo.sh +9 -0
- bertrend_apps/prospective_demo/streamlit_utils.py +37 -0
- bertrend_apps/start_apps.sh +15 -0
- bertrend_apps/stop_apps.sh +11 -0
bertrend/BERTrend.py
ADDED
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
# Copyright (c) 2024, RTE (https://www.rte-france.com)
|
|
2
|
+
# See AUTHORS.txt
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This file is part of BERTrend.
|
|
5
|
+
import os
|
|
6
|
+
import pickle
|
|
7
|
+
|
|
8
|
+
import dill # improvement to pickle
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from bertopic import BERTopic
|
|
16
|
+
from loguru import logger
|
|
17
|
+
from pandas import Timestamp
|
|
18
|
+
from sentence_transformers import SentenceTransformer
|
|
19
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
20
|
+
from tqdm import tqdm
|
|
21
|
+
|
|
22
|
+
from bertrend import (
|
|
23
|
+
MODELS_DIR,
|
|
24
|
+
BERTREND_DEFAULT_CONFIG_PATH,
|
|
25
|
+
load_toml_config,
|
|
26
|
+
SIGNAL_EVOLUTION_DATA_DIR,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from bertrend.BERTopicModel import BERTopicModel
|
|
30
|
+
from bertrend.config.parameters import (
|
|
31
|
+
DOC_INFO_DF_FILE,
|
|
32
|
+
TOPIC_INFO_DF_FILE,
|
|
33
|
+
BERTOPIC_SERIALIZATION,
|
|
34
|
+
SIGNAL_CLASSIF_LOWER_BOUND,
|
|
35
|
+
SIGNAL_CLASSIF_UPPER_BOUND,
|
|
36
|
+
BERTREND_FILE,
|
|
37
|
+
LANGUAGES,
|
|
38
|
+
)
|
|
39
|
+
from bertrend.services.embedding_service import EmbeddingService
|
|
40
|
+
from bertrend.trend_analysis.weak_signals import (
|
|
41
|
+
_initialize_new_topic,
|
|
42
|
+
update_existing_topic,
|
|
43
|
+
_apply_decay_to_inactive_topics,
|
|
44
|
+
_filter_data,
|
|
45
|
+
_is_rising_popularity,
|
|
46
|
+
_create_dataframes,
|
|
47
|
+
)
|
|
48
|
+
from bertrend.utils.data_loading import TEXT_COLUMN
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BERTrend:
|
|
52
|
+
"""
|
|
53
|
+
A comprehensive trend analysis and weak signal detection tool using BERTopic.
|
|
54
|
+
|
|
55
|
+
This class provides functionality for analyzing trends and detecting weak signals
|
|
56
|
+
in text data over time using BERTopic models.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
embedding_model_name : str
|
|
61
|
+
Name of the embedding model to use
|
|
62
|
+
granularity : int
|
|
63
|
+
Number of days to group documents
|
|
64
|
+
min_chars : int
|
|
65
|
+
Minimum character length for documents
|
|
66
|
+
split_by_paragraph : bool
|
|
67
|
+
Whether to split documents by paragraph
|
|
68
|
+
sample_size : int
|
|
69
|
+
Number of documents to sample
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
config_file: str | Path = BERTREND_DEFAULT_CONFIG_PATH,
|
|
75
|
+
topic_model: BERTopicModel = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialize a class from a TOML config file.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
config_file : str or Path, default=BERTREND_DEFAULT_CONFIG_PATH
|
|
83
|
+
Configuration file path, which can be:
|
|
84
|
+
- a `str` representing the TOML file
|
|
85
|
+
- a `Path` to a TOML file
|
|
86
|
+
topic_model : BERTopicModel, optional
|
|
87
|
+
Pre-configured BERTopicModel instance. If None, a default instance will be created.
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
-----
|
|
91
|
+
To see file format and list of parameters: bertrend/config/bertrend_default_config.toml
|
|
92
|
+
"""
|
|
93
|
+
# Load configuration file
|
|
94
|
+
self.config_file = config_file
|
|
95
|
+
self.config = self._load_config()
|
|
96
|
+
|
|
97
|
+
# Initialize topic model
|
|
98
|
+
self.topic_model = BERTopicModel() if topic_model is None else topic_model
|
|
99
|
+
|
|
100
|
+
# State variables of BERTrend
|
|
101
|
+
self._is_fitted = False
|
|
102
|
+
|
|
103
|
+
# Variables related to time-based topic models
|
|
104
|
+
# - last_topic_model: last trained BERTopic model (used for last timestamp)
|
|
105
|
+
self.last_topic_model: BERTopic = None
|
|
106
|
+
# - last_timestamp: timestamp associated to the last trained BERTopic model
|
|
107
|
+
self.last_topic_model_timestamp: pd.Timestamp = None
|
|
108
|
+
# - doc_groups: Dictionary of document groups for each timestamp.
|
|
109
|
+
self.doc_groups: dict[pd.Timestamp, list[str]] = {}
|
|
110
|
+
# - emb_groups: Dictionary of document embeddings for each timestamp.
|
|
111
|
+
self.emb_groups: dict[pd.Timestamp, np.ndarray] = {}
|
|
112
|
+
self.merge_df_size_over_time = []
|
|
113
|
+
|
|
114
|
+
# Variables containing info about merged topics
|
|
115
|
+
self.all_new_topics_df = None
|
|
116
|
+
self.all_merge_histories_df = None
|
|
117
|
+
self.merged_df = None
|
|
118
|
+
|
|
119
|
+
# Variables containing info about topic popularity
|
|
120
|
+
# - topic_sizes: Dictionary storing topic sizes and related information over time.
|
|
121
|
+
self.topic_sizes: dict[int, dict[str, Any]] = defaultdict(
|
|
122
|
+
lambda: defaultdict(list)
|
|
123
|
+
)
|
|
124
|
+
# - topic_last_popularity: Dictionary storing the last known popularity of each topic.
|
|
125
|
+
self.topic_last_popularity: dict[int, float] = {}
|
|
126
|
+
# - topic_last_update: Dictionary storing the last update timestamp of each topic.
|
|
127
|
+
self.topic_last_update: dict[int, pd.Timestamp] = {}
|
|
128
|
+
|
|
129
|
+
def _load_config(self) -> dict:
|
|
130
|
+
"""
|
|
131
|
+
Load the TOML config file as a dict when initializing the class.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
dict
|
|
136
|
+
The configuration dictionary loaded from the config file.
|
|
137
|
+
"""
|
|
138
|
+
config = load_toml_config(self.config_file)
|
|
139
|
+
return config
|
|
140
|
+
|
|
141
|
+
def get_periods(self):
|
|
142
|
+
"""
|
|
143
|
+
Return the time periods used by the model.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
list
|
|
148
|
+
List of timestamps representing the time periods used by the model.
|
|
149
|
+
"""
|
|
150
|
+
return list(self.doc_groups.keys())
|
|
151
|
+
|
|
152
|
+
def _train_by_period(
|
|
153
|
+
self,
|
|
154
|
+
period: pd.Timestamp,
|
|
155
|
+
group: pd.DataFrame,
|
|
156
|
+
embedding_model: SentenceTransformer,
|
|
157
|
+
embeddings: np.ndarray,
|
|
158
|
+
) -> tuple[
|
|
159
|
+
BERTopic,
|
|
160
|
+
list[str],
|
|
161
|
+
np.ndarray,
|
|
162
|
+
]:
|
|
163
|
+
"""
|
|
164
|
+
Train BERTopic models for a given time period from the grouped data.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
period : pd.Timestamp
|
|
169
|
+
Timestamp of the time period.
|
|
170
|
+
group : pd.DataFrame
|
|
171
|
+
Group of data associated to that timestamp.
|
|
172
|
+
embedding_model : SentenceTransformer
|
|
173
|
+
Sentence transformer model for embeddings.
|
|
174
|
+
embeddings : np.ndarray
|
|
175
|
+
Precomputed document embeddings.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
tuple
|
|
180
|
+
A tuple containing:
|
|
181
|
+
- BERTopic : Trained BERTopic model for this period.
|
|
182
|
+
- list[str] : Document groups for this period.
|
|
183
|
+
- np.ndarray : Document embeddings for this period.
|
|
184
|
+
"""
|
|
185
|
+
docs = group[TEXT_COLUMN].tolist()
|
|
186
|
+
embeddings_subset = embeddings[group.index]
|
|
187
|
+
|
|
188
|
+
logger.debug(f"Processing period: {period}")
|
|
189
|
+
logger.debug(f"Number of documents: {len(docs)}")
|
|
190
|
+
|
|
191
|
+
logger.debug("Creating topic model...")
|
|
192
|
+
topic_model = self.topic_model.fit(
|
|
193
|
+
docs=docs,
|
|
194
|
+
embeddings=embeddings_subset,
|
|
195
|
+
).topic_model
|
|
196
|
+
|
|
197
|
+
logger.debug("Topic model created successfully")
|
|
198
|
+
|
|
199
|
+
doc_info_df = topic_model.get_document_info(docs=docs).rename(
|
|
200
|
+
columns={"Document": "Paragraph"}
|
|
201
|
+
)
|
|
202
|
+
doc_info_df = doc_info_df.merge(
|
|
203
|
+
group[[TEXT_COLUMN, "document_id", "source", "url"]],
|
|
204
|
+
left_on="Paragraph",
|
|
205
|
+
right_on=TEXT_COLUMN,
|
|
206
|
+
how="left",
|
|
207
|
+
)
|
|
208
|
+
doc_info_df = doc_info_df.drop(columns=[TEXT_COLUMN])
|
|
209
|
+
|
|
210
|
+
topic_info_df = topic_model.get_topic_info()
|
|
211
|
+
topic_doc_count_df = (
|
|
212
|
+
doc_info_df.groupby("Topic")["document_id"]
|
|
213
|
+
.nunique()
|
|
214
|
+
.reset_index(name="Document_Count")
|
|
215
|
+
)
|
|
216
|
+
topic_sources_df = (
|
|
217
|
+
doc_info_df.groupby("Topic")["source"]
|
|
218
|
+
.apply(list)
|
|
219
|
+
.reset_index(name="Sources")
|
|
220
|
+
)
|
|
221
|
+
topic_urls_df = (
|
|
222
|
+
doc_info_df.groupby("Topic")["url"].apply(list).reset_index(name="URLs")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
topic_info_df = topic_info_df.merge(topic_doc_count_df, on="Topic", how="left")
|
|
226
|
+
topic_info_df = topic_info_df.merge(topic_sources_df, on="Topic", how="left")
|
|
227
|
+
topic_info_df = topic_info_df.merge(topic_urls_df, on="Topic", how="left")
|
|
228
|
+
|
|
229
|
+
topic_info_df = topic_info_df[
|
|
230
|
+
[
|
|
231
|
+
"Topic",
|
|
232
|
+
"Count",
|
|
233
|
+
"Document_Count",
|
|
234
|
+
"Representation",
|
|
235
|
+
"Name",
|
|
236
|
+
"Representative_Docs",
|
|
237
|
+
"Sources",
|
|
238
|
+
"URLs",
|
|
239
|
+
]
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
topic_model.doc_info_df = doc_info_df
|
|
243
|
+
topic_model.topic_info_df = topic_info_df
|
|
244
|
+
return topic_model, docs, embeddings_subset
|
|
245
|
+
|
|
246
|
+
def train_topic_models(
|
|
247
|
+
self,
|
|
248
|
+
grouped_data: dict[pd.Timestamp, pd.DataFrame],
|
|
249
|
+
embedding_model: SentenceTransformer | str,
|
|
250
|
+
embeddings: np.ndarray,
|
|
251
|
+
bertrend_models_path: Path = MODELS_DIR,
|
|
252
|
+
save_topic_models: bool = True,
|
|
253
|
+
):
|
|
254
|
+
"""
|
|
255
|
+
Train BERTopic models for each timestamp in the grouped data.
|
|
256
|
+
|
|
257
|
+
This method trains BERTopic models for each timestamp in the grouped data and
|
|
258
|
+
stores the results in instance variables.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
grouped_data : dict[pd.Timestamp, pd.DataFrame]
|
|
263
|
+
Dictionary of grouped data by timestamp.
|
|
264
|
+
embedding_model : SentenceTransformer or str
|
|
265
|
+
Sentence transformer model for embeddings.
|
|
266
|
+
embeddings : np.ndarray
|
|
267
|
+
Precomputed document embeddings.
|
|
268
|
+
bertrend_models_path : Path, default=MODELS_DIR
|
|
269
|
+
Path to BERTrend models folder.
|
|
270
|
+
save_topic_models : bool, default=True
|
|
271
|
+
Boolean flag to save topic models.
|
|
272
|
+
|
|
273
|
+
Notes
|
|
274
|
+
-----
|
|
275
|
+
This method updates the following instance variables:
|
|
276
|
+
- doc_groups : Dictionary of document groups for each timestamp.
|
|
277
|
+
- emb_groups : Dictionary of document embeddings for each timestamp.
|
|
278
|
+
- last_topic_model : The most recently trained BERTopic model.
|
|
279
|
+
- last_topic_model_timestamp : The timestamp of the most recently trained model.
|
|
280
|
+
"""
|
|
281
|
+
non_empty_groups = [
|
|
282
|
+
(period, group) for period, group in grouped_data.items() if not group.empty
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
for i, (period, group) in enumerate(non_empty_groups):
|
|
286
|
+
try:
|
|
287
|
+
logger.info(f"Training topic model {i+1}/{len(non_empty_groups)}...")
|
|
288
|
+
(
|
|
289
|
+
new_topic_model,
|
|
290
|
+
self.doc_groups[period],
|
|
291
|
+
self.emb_groups[period],
|
|
292
|
+
) = self._train_by_period(
|
|
293
|
+
period, group, embedding_model, embeddings
|
|
294
|
+
) # TODO: parallelize?
|
|
295
|
+
|
|
296
|
+
if self.last_topic_model is not None:
|
|
297
|
+
self.merge_models_with(new_topic_model, period)
|
|
298
|
+
|
|
299
|
+
if save_topic_models:
|
|
300
|
+
logger.info(f"Saving topic model for period {period}...")
|
|
301
|
+
# save new model to disk for potential reuse
|
|
302
|
+
BERTrend.save_topic_model(
|
|
303
|
+
period, new_topic_model, bertrend_models_path
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Update last topic model
|
|
307
|
+
self.last_topic_model = new_topic_model
|
|
308
|
+
self.last_topic_model_timestamp = period
|
|
309
|
+
|
|
310
|
+
logger.debug(f"Successfully processed period: {period}")
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Error processing period {period}: {e}")
|
|
314
|
+
logger.exception("Traceback:")
|
|
315
|
+
continue # TODO: better error handling
|
|
316
|
+
|
|
317
|
+
self._is_fitted = True
|
|
318
|
+
|
|
319
|
+
logger.success("Finished training all topic models")
|
|
320
|
+
|
|
321
|
+
def merge_models_with(
|
|
322
|
+
self,
|
|
323
|
+
new_model: BERTopic,
|
|
324
|
+
new_model_timestamp: pd.Timestamp,
|
|
325
|
+
min_similarity: int | None = None,
|
|
326
|
+
):
|
|
327
|
+
"""
|
|
328
|
+
Merge two specific topic models.
|
|
329
|
+
|
|
330
|
+
Parameters
|
|
331
|
+
----------
|
|
332
|
+
new_model : BERTopic
|
|
333
|
+
The new topic model to merge with the last trained model.
|
|
334
|
+
new_model_timestamp : pd.Timestamp
|
|
335
|
+
Timestamp associated with the new model.
|
|
336
|
+
min_similarity : int or None, default=None
|
|
337
|
+
Minimum similarity threshold for merging topics. If None, uses the value from config.
|
|
338
|
+
|
|
339
|
+
Raises
|
|
340
|
+
------
|
|
341
|
+
ValueError
|
|
342
|
+
If either the new model or the last topic model is not valid.
|
|
343
|
+
|
|
344
|
+
Notes
|
|
345
|
+
-----
|
|
346
|
+
This method updates the merged_df, all_merge_histories_df, and all_new_topics_df instance variables.
|
|
347
|
+
"""
|
|
348
|
+
logger.debug(
|
|
349
|
+
f"Merging topic models for timestamps: {self.last_topic_model_timestamp} and {new_model_timestamp}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Check if both timestamps exist in topic models
|
|
353
|
+
if new_model is None or self.last_topic_model is None:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"At least one topic model is not valid. You must fit the BERTrend model before merging models."
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Get default BERTrend config if argument is not provided
|
|
359
|
+
if min_similarity is None:
|
|
360
|
+
min_similarity = self.config["min_similarity"]
|
|
361
|
+
|
|
362
|
+
merged_df_without_outliers = self.merged_df
|
|
363
|
+
|
|
364
|
+
if merged_df_without_outliers is None:
|
|
365
|
+
# Preprocess the two specific models
|
|
366
|
+
topic_df1 = _preprocess_model(
|
|
367
|
+
self.last_topic_model,
|
|
368
|
+
self.doc_groups[self.last_topic_model_timestamp],
|
|
369
|
+
self.emb_groups[self.last_topic_model_timestamp],
|
|
370
|
+
)
|
|
371
|
+
# Remove outliers (Topic == -1)
|
|
372
|
+
df1 = topic_df1[topic_df1["Topic"] != -1]
|
|
373
|
+
merged_df_without_outliers = df1
|
|
374
|
+
|
|
375
|
+
topic_df2 = _preprocess_model(
|
|
376
|
+
new_model,
|
|
377
|
+
self.doc_groups[new_model_timestamp],
|
|
378
|
+
self.emb_groups[new_model_timestamp],
|
|
379
|
+
)
|
|
380
|
+
# Remove outliers (Topic == -1)
|
|
381
|
+
df2 = topic_df2[topic_df2["Topic"] != -1]
|
|
382
|
+
|
|
383
|
+
# Check if either dataframe is empty
|
|
384
|
+
if merged_df_without_outliers.empty or df2.empty:
|
|
385
|
+
logger.warning(
|
|
386
|
+
f"One of the dataframes is empty. Skipping merge for {new_model_timestamp}"
|
|
387
|
+
)
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
# Merge the two models
|
|
391
|
+
(
|
|
392
|
+
merged_df_without_outliers,
|
|
393
|
+
merge_history,
|
|
394
|
+
new_topics,
|
|
395
|
+
) = _merge_models(
|
|
396
|
+
merged_df_without_outliers,
|
|
397
|
+
df2,
|
|
398
|
+
min_similarity=min_similarity,
|
|
399
|
+
timestamp=self.last_topic_model_timestamp,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Store results
|
|
403
|
+
self.merged_df = merged_df_without_outliers
|
|
404
|
+
self.all_merge_histories_df = pd.concat(
|
|
405
|
+
[self.all_merge_histories_df, merge_history], ignore_index=True
|
|
406
|
+
)
|
|
407
|
+
self.all_new_topics_df = pd.concat(
|
|
408
|
+
[self.all_new_topics_df, new_topics], ignore_index=True
|
|
409
|
+
)
|
|
410
|
+
self.merge_df_size_over_time.append(
|
|
411
|
+
(
|
|
412
|
+
new_model_timestamp,
|
|
413
|
+
merged_df_without_outliers["Topic"].max() + 1,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
logger.success(f"Models {new_model_timestamp} merged successfully with others")
|
|
417
|
+
|
|
418
|
+
def calculate_signal_popularity(
|
|
419
|
+
self,
|
|
420
|
+
decay_factor: float | None = None,
|
|
421
|
+
decay_power: float | None = None,
|
|
422
|
+
):
|
|
423
|
+
"""
|
|
424
|
+
Compute the popularity of signals (topics) over time, accounting for merges and applying decay.
|
|
425
|
+
|
|
426
|
+
Parameters
|
|
427
|
+
----------
|
|
428
|
+
decay_factor : float or None, default=None
|
|
429
|
+
Factor for exponential decay calculation. If None, uses the value from config.
|
|
430
|
+
decay_power : float or None, default=None
|
|
431
|
+
Power for exponential decay calculation. If None, uses the value from config.
|
|
432
|
+
|
|
433
|
+
Notes
|
|
434
|
+
-----
|
|
435
|
+
This method updates the following instance variables:
|
|
436
|
+
- topic_sizes : Dict[int, Dict[str, Any]]
|
|
437
|
+
Dictionary storing topic sizes and related information over time.
|
|
438
|
+
- topic_last_popularity : Dict[int, float]
|
|
439
|
+
Dictionary storing the last known popularity of each topic.
|
|
440
|
+
- topic_last_update : Dict[int, pd.Timestamp]
|
|
441
|
+
Dictionary storing the last update timestamp of each topic.
|
|
442
|
+
|
|
443
|
+
Raises
|
|
444
|
+
------
|
|
445
|
+
ValueError
|
|
446
|
+
If less than two topic models have been trained.
|
|
447
|
+
"""
|
|
448
|
+
# Get default BERTrend config if argument is not provided
|
|
449
|
+
if decay_factor is None:
|
|
450
|
+
decay_factor = self.config["decay_factor"]
|
|
451
|
+
if decay_power is None:
|
|
452
|
+
decay_power = self.config["decay_power"]
|
|
453
|
+
|
|
454
|
+
# Check if models are merged
|
|
455
|
+
if len(self.doc_groups) < 2:
|
|
456
|
+
logger.error(
|
|
457
|
+
"You must train at least two topic models first before computing signal popularity."
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
topic_sizes = defaultdict(lambda: defaultdict(list))
|
|
462
|
+
topic_last_popularity = {}
|
|
463
|
+
topic_last_update = {}
|
|
464
|
+
|
|
465
|
+
min_timestamp = self.all_merge_histories_df["Timestamp"].min()
|
|
466
|
+
max_timestamp = self.all_merge_histories_df["Timestamp"].max()
|
|
467
|
+
granularity_timedelta = pd.Timedelta(days=self.config["granularity"])
|
|
468
|
+
time_range = pd.date_range(
|
|
469
|
+
start=min_timestamp.to_pydatetime(),
|
|
470
|
+
end=(max_timestamp + granularity_timedelta).to_pydatetime(),
|
|
471
|
+
freq=granularity_timedelta,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
for current_timestamp in time_range:
|
|
475
|
+
current_df = self.all_merge_histories_df[
|
|
476
|
+
self.all_merge_histories_df["Timestamp"] == current_timestamp
|
|
477
|
+
]
|
|
478
|
+
updated_topics = set()
|
|
479
|
+
|
|
480
|
+
# Process active topics (those appearing in the current timestamp)
|
|
481
|
+
for _, row in current_df.iterrows():
|
|
482
|
+
current_topic = row["Topic1"]
|
|
483
|
+
updated_topics.add(current_topic)
|
|
484
|
+
|
|
485
|
+
if current_topic not in topic_sizes:
|
|
486
|
+
# Initialize new topic
|
|
487
|
+
_initialize_new_topic(
|
|
488
|
+
topic_sizes,
|
|
489
|
+
topic_last_popularity,
|
|
490
|
+
topic_last_update,
|
|
491
|
+
current_topic,
|
|
492
|
+
current_timestamp,
|
|
493
|
+
row,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Update existing topic
|
|
497
|
+
update_existing_topic(
|
|
498
|
+
topic_sizes,
|
|
499
|
+
topic_last_popularity,
|
|
500
|
+
topic_last_update,
|
|
501
|
+
current_topic,
|
|
502
|
+
current_timestamp,
|
|
503
|
+
granularity_timedelta,
|
|
504
|
+
row,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Mark the topic as updated for the next timestamp
|
|
508
|
+
updated_topics.add(current_topic)
|
|
509
|
+
|
|
510
|
+
# Apply decay to topics that weren't updated in this timestamp or the next
|
|
511
|
+
next_timestamp = current_timestamp + granularity_timedelta
|
|
512
|
+
next_df = self.all_merge_histories_df[
|
|
513
|
+
self.all_merge_histories_df["Timestamp"] == next_timestamp
|
|
514
|
+
]
|
|
515
|
+
topics_updated_next = set(next_df["Topic1"])
|
|
516
|
+
|
|
517
|
+
_apply_decay_to_inactive_topics(
|
|
518
|
+
topic_sizes,
|
|
519
|
+
topic_last_popularity,
|
|
520
|
+
topic_last_update,
|
|
521
|
+
updated_topics,
|
|
522
|
+
topics_updated_next,
|
|
523
|
+
current_timestamp,
|
|
524
|
+
granularity_timedelta,
|
|
525
|
+
decay_factor,
|
|
526
|
+
decay_power,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
self.topic_sizes = topic_sizes
|
|
530
|
+
self.topic_last_popularity = topic_last_popularity
|
|
531
|
+
self.topic_last_update = topic_last_update
|
|
532
|
+
|
|
533
|
+
def _compute_popularity_values_and_thresholds(
|
|
534
|
+
self, window_size: int, current_date: Timestamp
|
|
535
|
+
) -> tuple[Timestamp, Timestamp, list, float, float]:
|
|
536
|
+
"""
|
|
537
|
+
Compute the popularity values and thresholds for the considered time window.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
window_size : int
|
|
542
|
+
The retrospective window size in days.
|
|
543
|
+
current_date : Timestamp
|
|
544
|
+
The current date selected by the user.
|
|
545
|
+
|
|
546
|
+
Returns
|
|
547
|
+
-------
|
|
548
|
+
tuple
|
|
549
|
+
A tuple containing:
|
|
550
|
+
- window_start : Timestamp
|
|
551
|
+
Start timestamp of the time window.
|
|
552
|
+
- window_end : Timestamp
|
|
553
|
+
End timestamp of the time window.
|
|
554
|
+
- all_popularity_values : list
|
|
555
|
+
List of all popularity values within the time window.
|
|
556
|
+
- q1 : float
|
|
557
|
+
The lower threshold value (10th percentile of popularity values).
|
|
558
|
+
- q3 : float
|
|
559
|
+
The upper threshold value (90th percentile of popularity values).
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
window_size_timedelta = pd.Timedelta(days=window_size)
|
|
563
|
+
granularity_timedelta = pd.Timedelta(days=self.config["granularity"])
|
|
564
|
+
|
|
565
|
+
current_date = pd.to_datetime(current_date).floor("D") # Floor to start of day
|
|
566
|
+
window_start = current_date - window_size_timedelta
|
|
567
|
+
window_end = current_date + granularity_timedelta
|
|
568
|
+
|
|
569
|
+
# Calculate q1 and q3 values (we remove very low values of disappearing signals to not skew the thresholds)
|
|
570
|
+
all_popularity_values = [
|
|
571
|
+
popularity
|
|
572
|
+
for topic, data in self.topic_sizes.items()
|
|
573
|
+
for timestamp, popularity in zip(
|
|
574
|
+
pd.to_datetime(data["Timestamps"]), data["Popularity"]
|
|
575
|
+
)
|
|
576
|
+
if window_start <= timestamp <= current_date and popularity > 1e-5
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
if all_popularity_values:
|
|
580
|
+
q1 = np.percentile(all_popularity_values, SIGNAL_CLASSIF_LOWER_BOUND)
|
|
581
|
+
q3 = np.percentile(all_popularity_values, SIGNAL_CLASSIF_UPPER_BOUND)
|
|
582
|
+
else:
|
|
583
|
+
q1, q3 = 0, 0
|
|
584
|
+
|
|
585
|
+
return window_start, window_end, all_popularity_values, q1, q3
|
|
586
|
+
|
|
587
|
+
def _classify_signals(
|
|
588
|
+
self,
|
|
589
|
+
window_start: pd.Timestamp,
|
|
590
|
+
window_end: pd.Timestamp,
|
|
591
|
+
q1: float,
|
|
592
|
+
q3: float,
|
|
593
|
+
rising_popularity_only: bool = True,
|
|
594
|
+
keep_documents: bool = True,
|
|
595
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
596
|
+
"""
|
|
597
|
+
Classify signals into weak signal and strong signal dataframes.
|
|
598
|
+
|
|
599
|
+
Parameters
|
|
600
|
+
----------
|
|
601
|
+
window_start : pd.Timestamp
|
|
602
|
+
The start timestamp of the window.
|
|
603
|
+
window_end : pd.Timestamp
|
|
604
|
+
The end timestamp of the window.
|
|
605
|
+
q1 : float
|
|
606
|
+
The lower threshold value (10th percentile of popularity values).
|
|
607
|
+
q3 : float
|
|
608
|
+
The upper threshold value (90th percentile of popularity values).
|
|
609
|
+
rising_popularity_only : bool, default=True
|
|
610
|
+
Whether to consider only rising popularity topics as weak signals.
|
|
611
|
+
keep_documents : bool, default=True
|
|
612
|
+
Whether to keep track of the documents or not.
|
|
613
|
+
|
|
614
|
+
Returns
|
|
615
|
+
-------
|
|
616
|
+
tuple
|
|
617
|
+
A tuple containing:
|
|
618
|
+
- noise_topics_df : pd.DataFrame
|
|
619
|
+
DataFrame containing noise topics.
|
|
620
|
+
- weak_signal_topics_df : pd.DataFrame
|
|
621
|
+
DataFrame containing weak signal topics.
|
|
622
|
+
- strong_signal_topics_df : pd.DataFrame
|
|
623
|
+
DataFrame containing strong signal topics.
|
|
624
|
+
"""
|
|
625
|
+
noise_topics = []
|
|
626
|
+
weak_signal_topics = []
|
|
627
|
+
strong_signal_topics = []
|
|
628
|
+
|
|
629
|
+
sorted_topics = sorted(self.topic_sizes.items(), key=lambda x: x[0])
|
|
630
|
+
|
|
631
|
+
for topic, data in sorted_topics:
|
|
632
|
+
filtered_data = _filter_data(data, window_end, keep_documents)
|
|
633
|
+
if not filtered_data["Timestamps"]:
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
window_popularities = [
|
|
637
|
+
(timestamp, popularity)
|
|
638
|
+
for timestamp, popularity in zip(
|
|
639
|
+
filtered_data["Timestamps"], filtered_data["Popularity"]
|
|
640
|
+
)
|
|
641
|
+
if window_start <= timestamp <= window_end
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
if window_popularities:
|
|
645
|
+
latest_timestamp, latest_popularity = window_popularities[-1]
|
|
646
|
+
docs_count = (
|
|
647
|
+
filtered_data["Docs_Count"][-1]
|
|
648
|
+
if filtered_data["Docs_Count"]
|
|
649
|
+
else 0
|
|
650
|
+
)
|
|
651
|
+
paragraphs_count = (
|
|
652
|
+
filtered_data["Paragraphs_Count"][-1]
|
|
653
|
+
if filtered_data["Paragraphs_Count"]
|
|
654
|
+
else 0
|
|
655
|
+
)
|
|
656
|
+
source_diversity = (
|
|
657
|
+
filtered_data["Source_Diversity"][-1]
|
|
658
|
+
if filtered_data["Source_Diversity"]
|
|
659
|
+
else 0
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
topic_data = (
|
|
663
|
+
topic,
|
|
664
|
+
latest_popularity,
|
|
665
|
+
latest_timestamp,
|
|
666
|
+
docs_count,
|
|
667
|
+
paragraphs_count,
|
|
668
|
+
source_diversity,
|
|
669
|
+
filtered_data,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
if latest_popularity < q1:
|
|
673
|
+
noise_topics.append(topic_data)
|
|
674
|
+
elif q1 <= latest_popularity <= q3:
|
|
675
|
+
if rising_popularity_only:
|
|
676
|
+
if _is_rising_popularity(filtered_data, latest_timestamp):
|
|
677
|
+
weak_signal_topics.append(topic_data)
|
|
678
|
+
else:
|
|
679
|
+
noise_topics.append(topic_data)
|
|
680
|
+
else:
|
|
681
|
+
weak_signal_topics.append(topic_data)
|
|
682
|
+
else:
|
|
683
|
+
strong_signal_topics.append(topic_data)
|
|
684
|
+
|
|
685
|
+
return _create_dataframes(
|
|
686
|
+
noise_topics, weak_signal_topics, strong_signal_topics, keep_documents
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def classify_signals(
|
|
690
|
+
self, window_size: int, current_date: Timestamp
|
|
691
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
692
|
+
"""
|
|
693
|
+
Classify signals into weak signal and strong signal dataframes for the considered time window.
|
|
694
|
+
|
|
695
|
+
Parameters
|
|
696
|
+
----------
|
|
697
|
+
window_size : int
|
|
698
|
+
The retrospective window size in days.
|
|
699
|
+
current_date : Timestamp
|
|
700
|
+
The current date selected by the user.
|
|
701
|
+
|
|
702
|
+
Returns
|
|
703
|
+
-------
|
|
704
|
+
tuple
|
|
705
|
+
A tuple containing:
|
|
706
|
+
- noise_topics_df : pd.DataFrame
|
|
707
|
+
DataFrame containing noise topics.
|
|
708
|
+
- weak_signal_topics_df : pd.DataFrame
|
|
709
|
+
DataFrame containing weak signal topics.
|
|
710
|
+
- strong_signal_topics_df : pd.DataFrame
|
|
711
|
+
DataFrame containing strong signal topics.
|
|
712
|
+
"""
|
|
713
|
+
# Compute threshold values
|
|
714
|
+
window_start, window_end, all_popularity_values, q1, q3 = (
|
|
715
|
+
self._compute_popularity_values_and_thresholds(window_size, current_date)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Classify signals
|
|
719
|
+
noise_topics_df, weak_signal_topics_df, strong_signal_topics_df = (
|
|
720
|
+
self._classify_signals(window_start, window_end, q1, q3)
|
|
721
|
+
)
|
|
722
|
+
return noise_topics_df, weak_signal_topics_df, strong_signal_topics_df
|
|
723
|
+
|
|
724
|
+
def save_model(self, models_path: Path = MODELS_DIR):
|
|
725
|
+
"""
|
|
726
|
+
Save BERTrend model to disk.
|
|
727
|
+
|
|
728
|
+
Parameters
|
|
729
|
+
----------
|
|
730
|
+
models_path : Path, default=MODELS_DIR
|
|
731
|
+
Path to the directory where the model will be saved.
|
|
732
|
+
|
|
733
|
+
Notes
|
|
734
|
+
-----
|
|
735
|
+
This method serializes the entire BERTrend object using dill and saves it to the specified path.
|
|
736
|
+
"""
|
|
737
|
+
models_path.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
# Serialize BERTrend object (using dill as an improvement of pickle for complex objects)
|
|
739
|
+
with open(models_path / BERTREND_FILE, "wb") as f:
|
|
740
|
+
dill.dump(self, f)
|
|
741
|
+
logger.info(f"BERTrend model saved to: {models_path}")
|
|
742
|
+
|
|
743
|
+
@classmethod
|
|
744
|
+
def restore_model(cls, models_path: Path = MODELS_DIR) -> "BERTrend":
|
|
745
|
+
"""
|
|
746
|
+
Restore a previously saved BERTrend model from disk.
|
|
747
|
+
|
|
748
|
+
Parameters
|
|
749
|
+
----------
|
|
750
|
+
models_path : Path, default=MODELS_DIR
|
|
751
|
+
Path to the directory where the model was saved.
|
|
752
|
+
|
|
753
|
+
Returns
|
|
754
|
+
-------
|
|
755
|
+
BERTrend
|
|
756
|
+
The restored BERTrend model instance.
|
|
757
|
+
|
|
758
|
+
Raises
|
|
759
|
+
------
|
|
760
|
+
FileNotFoundError
|
|
761
|
+
If the specified models_path does not exist.
|
|
762
|
+
|
|
763
|
+
Notes
|
|
764
|
+
-----
|
|
765
|
+
This method deserializes a BERTrend object using dill from the specified path.
|
|
766
|
+
"""
|
|
767
|
+
if not models_path.exists():
|
|
768
|
+
raise FileNotFoundError(f"models_path={models_path} does not exist")
|
|
769
|
+
logger.info(f"Loading BERTrend model from: {models_path}")
|
|
770
|
+
# Unserialize BERTrend object (using dill as an improvement of pickle for complex objects)
|
|
771
|
+
with open(models_path / BERTREND_FILE, "rb") as f:
|
|
772
|
+
bertrend = dill.load(f)
|
|
773
|
+
return bertrend
|
|
774
|
+
|
|
775
|
+
@classmethod
|
|
776
|
+
def save_topic_model(
|
|
777
|
+
cls, period: pd.Timestamp, topic_model: BERTopic, models_path: Path = MODELS_DIR
|
|
778
|
+
):
|
|
779
|
+
"""
|
|
780
|
+
Serialize a topic model to disk for potential reuse.
|
|
781
|
+
|
|
782
|
+
Parameters
|
|
783
|
+
----------
|
|
784
|
+
period : pd.Timestamp
|
|
785
|
+
Timestamp associated with the topic model.
|
|
786
|
+
topic_model : BERTopic
|
|
787
|
+
The BERTopic model to save.
|
|
788
|
+
models_path : Path, default=MODELS_DIR
|
|
789
|
+
Path to the directory where the model will be saved.
|
|
790
|
+
|
|
791
|
+
Notes
|
|
792
|
+
-----
|
|
793
|
+
This method saves the BERTopic model along with its associated document and topic information
|
|
794
|
+
to a directory named with the period's date (YYYY-MM-DD format).
|
|
795
|
+
"""
|
|
796
|
+
models_path.mkdir(parents=True, exist_ok=True)
|
|
797
|
+
model_dir = models_path / period.strftime("%Y-%m-%d")
|
|
798
|
+
model_dir.mkdir(exist_ok=True)
|
|
799
|
+
embedding_model = topic_model.embedding_model
|
|
800
|
+
topic_model.save(
|
|
801
|
+
model_dir,
|
|
802
|
+
serialization=BERTOPIC_SERIALIZATION,
|
|
803
|
+
save_ctfidf=False,
|
|
804
|
+
save_embedding_model=embedding_model,
|
|
805
|
+
)
|
|
806
|
+
topic_model.doc_info_df.to_pickle(model_dir / DOC_INFO_DF_FILE)
|
|
807
|
+
topic_model.topic_info_df.to_pickle(model_dir / TOPIC_INFO_DF_FILE)
|
|
808
|
+
|
|
809
|
+
@classmethod
|
|
810
|
+
def restore_topic_model(
|
|
811
|
+
cls, period: pd.Timestamp, models_path: Path = MODELS_DIR
|
|
812
|
+
) -> BERTopic | None:
|
|
813
|
+
"""
|
|
814
|
+
Restore a previously stored topic model.
|
|
815
|
+
|
|
816
|
+
Parameters
|
|
817
|
+
----------
|
|
818
|
+
period : pd.Timestamp
|
|
819
|
+
Timestamp associated with the topic model to restore.
|
|
820
|
+
models_path : Path, default=MODELS_DIR
|
|
821
|
+
Path to the directory where the model was saved.
|
|
822
|
+
|
|
823
|
+
Returns
|
|
824
|
+
-------
|
|
825
|
+
BERTopic or None
|
|
826
|
+
The restored BERTopic model if found, None otherwise.
|
|
827
|
+
|
|
828
|
+
Raises
|
|
829
|
+
------
|
|
830
|
+
FileNotFoundError
|
|
831
|
+
If the specified models_path does not exist.
|
|
832
|
+
|
|
833
|
+
Notes
|
|
834
|
+
-----
|
|
835
|
+
This method looks for a directory named with the period's date (YYYY-MM-DD format)
|
|
836
|
+
and attempts to load the BERTopic model along with its associated document and topic information.
|
|
837
|
+
"""
|
|
838
|
+
if not models_path.exists():
|
|
839
|
+
raise FileNotFoundError(f"models_path={models_path} does not exist")
|
|
840
|
+
# Restore topic models using the selected serialization type
|
|
841
|
+
for period_dir in models_path.glob(
|
|
842
|
+
r"????-??-??"
|
|
843
|
+
): # filter dir that are formatted YYYY-MM-DD
|
|
844
|
+
if period_dir.is_dir():
|
|
845
|
+
if period_dir.name == period.strftime("%Y-%m-%d"):
|
|
846
|
+
topic_model = BERTopic.load(period_dir)
|
|
847
|
+
doc_info_df_file = period_dir / DOC_INFO_DF_FILE
|
|
848
|
+
topic_info_df_file = period_dir / TOPIC_INFO_DF_FILE
|
|
849
|
+
if doc_info_df_file.exists() and topic_info_df_file.exists():
|
|
850
|
+
topic_model.doc_info_df = pd.read_pickle(doc_info_df_file)
|
|
851
|
+
topic_model.topic_info_df = pd.read_pickle(topic_info_df_file)
|
|
852
|
+
else:
|
|
853
|
+
logger.warning(
|
|
854
|
+
f"doc_info_df or topic_info_df not found for period {period_dir.name}"
|
|
855
|
+
)
|
|
856
|
+
return topic_model
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
def restore_topic_models(
|
|
860
|
+
self, models_path: Path = MODELS_DIR
|
|
861
|
+
) -> dict[pd.Timestamp, BERTopic] | None:
|
|
862
|
+
"""
|
|
863
|
+
Restore all previously stored topic models for the periods in this BERTrend instance.
|
|
864
|
+
|
|
865
|
+
Parameters
|
|
866
|
+
----------
|
|
867
|
+
models_path : Path, default=MODELS_DIR
|
|
868
|
+
Path to the directory where the models were saved.
|
|
869
|
+
|
|
870
|
+
Returns
|
|
871
|
+
-------
|
|
872
|
+
dict or None
|
|
873
|
+
A dictionary mapping timestamps to their corresponding BERTopic models.
|
|
874
|
+
Returns an empty dictionary if no models are found.
|
|
875
|
+
|
|
876
|
+
Notes
|
|
877
|
+
-----
|
|
878
|
+
This method attempts to restore topic models for all periods that exist in the
|
|
879
|
+
current BERTrend instance by calling restore_topic_model for each period.
|
|
880
|
+
"""
|
|
881
|
+
topic_models = {}
|
|
882
|
+
for ts in self.get_periods():
|
|
883
|
+
tm = self.restore_topic_model(period=ts, models_path=models_path)
|
|
884
|
+
if tm:
|
|
885
|
+
topic_models[ts] = tm
|
|
886
|
+
return topic_models
|
|
887
|
+
|
|
888
|
+
def save_signal_evolution_data(
|
|
889
|
+
self,
|
|
890
|
+
window_size: int,
|
|
891
|
+
start_timestamp: pd.Timestamp,
|
|
892
|
+
end_timestamp: pd.Timestamp,
|
|
893
|
+
) -> Path:
|
|
894
|
+
"""
|
|
895
|
+
Save signal evolution data for a range of timestamps.
|
|
896
|
+
|
|
897
|
+
Parameters
|
|
898
|
+
----------
|
|
899
|
+
window_size : int
|
|
900
|
+
The retrospective window size in days.
|
|
901
|
+
start_timestamp : pd.Timestamp
|
|
902
|
+
The start timestamp of the range to process.
|
|
903
|
+
end_timestamp : pd.Timestamp
|
|
904
|
+
The end timestamp of the range to process.
|
|
905
|
+
|
|
906
|
+
Returns
|
|
907
|
+
-------
|
|
908
|
+
Path
|
|
909
|
+
Path to the directory where the signal evolution data was saved.
|
|
910
|
+
|
|
911
|
+
Notes
|
|
912
|
+
-----
|
|
913
|
+
This method processes each timestamp in the specified range, classifies signals,
|
|
914
|
+
and saves the results to disk for later analysis. The data is saved in a directory
|
|
915
|
+
named 'retrospective_{window_size}_days' within the SIGNAL_EVOLUTION_DATA_DIR.
|
|
916
|
+
"""
|
|
917
|
+
save_path = SIGNAL_EVOLUTION_DATA_DIR / f"retrospective_{window_size}_days"
|
|
918
|
+
os.makedirs(save_path, exist_ok=True)
|
|
919
|
+
|
|
920
|
+
q1_values, q3_values, timestamps_over_time = [], [], []
|
|
921
|
+
noise_dfs, weak_signal_dfs, strong_signal_dfs = [], [], []
|
|
922
|
+
|
|
923
|
+
for current_timestamp in tqdm(
|
|
924
|
+
pd.date_range(
|
|
925
|
+
start=start_timestamp,
|
|
926
|
+
end=end_timestamp,
|
|
927
|
+
freq=pd.Timedelta(days=self.config["granularity"]),
|
|
928
|
+
),
|
|
929
|
+
desc="Processing timestamps",
|
|
930
|
+
):
|
|
931
|
+
window_start, window_end, all_popularity_values, q1, q3 = (
|
|
932
|
+
self._compute_popularity_values_and_thresholds(
|
|
933
|
+
window_size, current_timestamp
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
noise_df, weak_signal_df, strong_signal_df = self._classify_signals(
|
|
938
|
+
window_start, window_end, q1, q3, keep_documents=False
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
noise_dfs.append(noise_df)
|
|
942
|
+
weak_signal_dfs.append(weak_signal_df)
|
|
943
|
+
strong_signal_dfs.append(strong_signal_df)
|
|
944
|
+
|
|
945
|
+
timestamps_over_time.append(current_timestamp)
|
|
946
|
+
|
|
947
|
+
# Save the grouped dataframes
|
|
948
|
+
with open(save_path / "noise_dfs_over_time.pkl", "wb") as f:
|
|
949
|
+
pickle.dump(noise_dfs, f)
|
|
950
|
+
with open(save_path / "weak_signal_dfs_over_time.pkl", "wb") as f:
|
|
951
|
+
pickle.dump(weak_signal_dfs, f)
|
|
952
|
+
with open(save_path / "strong_signal_dfs_over_time.pkl", "wb") as f:
|
|
953
|
+
pickle.dump(strong_signal_dfs, f)
|
|
954
|
+
|
|
955
|
+
# Save the metadata
|
|
956
|
+
with open(save_path / "metadata.pkl", "wb") as f:
|
|
957
|
+
metadata = {
|
|
958
|
+
"window_size": window_size,
|
|
959
|
+
"granularity": self.config["granularity"],
|
|
960
|
+
"timestamps": timestamps_over_time,
|
|
961
|
+
"q1_values": q1_values,
|
|
962
|
+
"q3_values": q3_values,
|
|
963
|
+
}
|
|
964
|
+
pickle.dump(metadata, f)
|
|
965
|
+
|
|
966
|
+
return save_path
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def train_new_data(
|
|
970
|
+
reference_timestamp: pd.Timestamp,
|
|
971
|
+
new_data: pd.DataFrame,
|
|
972
|
+
bertrend_models_path: Path,
|
|
973
|
+
embedding_service: EmbeddingService,
|
|
974
|
+
granularity: int,
|
|
975
|
+
language: str,
|
|
976
|
+
) -> BERTrend:
|
|
977
|
+
"""
|
|
978
|
+
Process new data for incremental trend analysis.
|
|
979
|
+
|
|
980
|
+
Parameters
|
|
981
|
+
----------
|
|
982
|
+
reference_timestamp: pd.Timestamp
|
|
983
|
+
Reference timestamp to use for the topic model.
|
|
984
|
+
new_data : pd.DataFrame
|
|
985
|
+
DataFrame containing new text data to process.
|
|
986
|
+
bertrend_models_path : Path
|
|
987
|
+
Path to the directory where BERTrend models are stored.
|
|
988
|
+
embedding_service : EmbeddingService
|
|
989
|
+
Service for generating text embeddings.
|
|
990
|
+
granularity : int
|
|
991
|
+
Number of days to group documents.
|
|
992
|
+
language : str
|
|
993
|
+
Language of the text data.
|
|
994
|
+
|
|
995
|
+
Returns
|
|
996
|
+
-------
|
|
997
|
+
BERTrend
|
|
998
|
+
Updated BERTrend model with the new data incorporated.
|
|
999
|
+
|
|
1000
|
+
Notes
|
|
1001
|
+
-----
|
|
1002
|
+
This function performs the following steps:
|
|
1003
|
+
1. Loads a previously saved BERTrend model
|
|
1004
|
+
2. Trains a new topic model with the new data
|
|
1005
|
+
3. Merges the models and updates merge histories
|
|
1006
|
+
4. Saves the model and returns it
|
|
1007
|
+
|
|
1008
|
+
If no previous model exists, a new BERTrend model is created.
|
|
1009
|
+
"""
|
|
1010
|
+
logger.debug(f"Processing new data: {len(new_data)} items")
|
|
1011
|
+
logger.info(f"Reference timestamp: {reference_timestamp}")
|
|
1012
|
+
|
|
1013
|
+
# Restore previous models
|
|
1014
|
+
try:
|
|
1015
|
+
logger.info(f"Restoring previous BERTrend models from {bertrend_models_path}")
|
|
1016
|
+
bertrend = BERTrend.restore_model(bertrend_models_path)
|
|
1017
|
+
except:
|
|
1018
|
+
logger.warning("Cannot restore previous models, creating new one")
|
|
1019
|
+
# overrides default params
|
|
1020
|
+
if language and language in LANGUAGES:
|
|
1021
|
+
bertrend = BERTrend(
|
|
1022
|
+
topic_model=BERTopicModel({"global": {"language": language}})
|
|
1023
|
+
)
|
|
1024
|
+
else:
|
|
1025
|
+
bertrend = BERTrend(topic_model=BERTopicModel())
|
|
1026
|
+
bertrend.config["granularity"] = granularity
|
|
1027
|
+
|
|
1028
|
+
# Embed new data
|
|
1029
|
+
embeddings, token_strings, token_embeddings = embedding_service.embed(
|
|
1030
|
+
texts=new_data[TEXT_COLUMN]
|
|
1031
|
+
)
|
|
1032
|
+
embedding_model_name = embedding_service.embedding_model_name
|
|
1033
|
+
|
|
1034
|
+
# Create topic model for new data
|
|
1035
|
+
bertrend.train_topic_models(
|
|
1036
|
+
{reference_timestamp: new_data},
|
|
1037
|
+
embeddings=embeddings,
|
|
1038
|
+
embedding_model=embedding_model_name,
|
|
1039
|
+
bertrend_models_path=bertrend_models_path,
|
|
1040
|
+
save_topic_models=True,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
logger.info(f"BERTrend built from {len(bertrend.doc_groups)} periods")
|
|
1044
|
+
# Save model
|
|
1045
|
+
bertrend.save_model(models_path=bertrend_models_path)
|
|
1046
|
+
|
|
1047
|
+
return bertrend
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _preprocess_model(
|
|
1051
|
+
topic_model: BERTopic, docs: list[str], embeddings: np.ndarray
|
|
1052
|
+
) -> pd.DataFrame:
|
|
1053
|
+
"""
|
|
1054
|
+
Preprocess a BERTopic model by extracting topic information, document groups, document embeddings, and URLs.
|
|
1055
|
+
|
|
1056
|
+
Parameters
|
|
1057
|
+
----------
|
|
1058
|
+
topic_model : BERTopic
|
|
1059
|
+
A fitted BERTopic model.
|
|
1060
|
+
docs : list[str]
|
|
1061
|
+
List of documents.
|
|
1062
|
+
embeddings : np.ndarray
|
|
1063
|
+
Precomputed document embeddings.
|
|
1064
|
+
|
|
1065
|
+
Returns
|
|
1066
|
+
-------
|
|
1067
|
+
pd.DataFrame
|
|
1068
|
+
A DataFrame with topic information, document groups, document embeddings, and URLs.
|
|
1069
|
+
The DataFrame contains columns for Topic, Count, Document_Count, Representation,
|
|
1070
|
+
Documents, Embedding, DocEmbeddings, Sources, and URLs.
|
|
1071
|
+
"""
|
|
1072
|
+
topic_info = topic_model.topic_info_df
|
|
1073
|
+
doc_info = topic_model.doc_info_df
|
|
1074
|
+
doc_groups = doc_info.groupby("Topic")["Paragraph"].apply(list)
|
|
1075
|
+
|
|
1076
|
+
topic_doc_embeddings = []
|
|
1077
|
+
topic_embeddings = []
|
|
1078
|
+
topic_sources = []
|
|
1079
|
+
topic_urls = []
|
|
1080
|
+
|
|
1081
|
+
for topic_docs in doc_groups:
|
|
1082
|
+
doc_embeddings = [embeddings[docs.index(doc)] for doc in topic_docs]
|
|
1083
|
+
topic_doc_embeddings.append(doc_embeddings)
|
|
1084
|
+
topic_embeddings.append(np.mean(doc_embeddings, axis=0))
|
|
1085
|
+
topic_sources.append(
|
|
1086
|
+
doc_info[doc_info["Paragraph"].isin(topic_docs)]["source"].tolist()
|
|
1087
|
+
)
|
|
1088
|
+
topic_urls.append(
|
|
1089
|
+
doc_info[doc_info["Paragraph"].isin(topic_docs)]["url"].tolist()
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
topic_df = pd.DataFrame(
|
|
1093
|
+
{
|
|
1094
|
+
"Topic": topic_info["Topic"],
|
|
1095
|
+
"Count": topic_info["Count"],
|
|
1096
|
+
"Document_Count": topic_info["Document_Count"],
|
|
1097
|
+
"Representation": topic_info["Representation"],
|
|
1098
|
+
"Documents": doc_groups.tolist(),
|
|
1099
|
+
"Embedding": topic_embeddings,
|
|
1100
|
+
"DocEmbeddings": topic_doc_embeddings,
|
|
1101
|
+
"Sources": topic_sources,
|
|
1102
|
+
"URLs": topic_urls,
|
|
1103
|
+
}
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
return topic_df
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _merge_models(
|
|
1110
|
+
df1: pd.DataFrame, df2: pd.DataFrame, min_similarity: float, timestamp: pd.Timestamp
|
|
1111
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
1112
|
+
"""
|
|
1113
|
+
Merge two topic model dataframes based on topic similarity.
|
|
1114
|
+
|
|
1115
|
+
Parameters
|
|
1116
|
+
----------
|
|
1117
|
+
df1 : pd.DataFrame
|
|
1118
|
+
First topic model dataframe (existing topics).
|
|
1119
|
+
df2 : pd.DataFrame
|
|
1120
|
+
Second topic model dataframe (new topics).
|
|
1121
|
+
min_similarity : float
|
|
1122
|
+
Minimum cosine similarity threshold for merging topics.
|
|
1123
|
+
timestamp : pd.Timestamp
|
|
1124
|
+
Timestamp associated with the merge operation.
|
|
1125
|
+
|
|
1126
|
+
Returns
|
|
1127
|
+
-------
|
|
1128
|
+
tuple
|
|
1129
|
+
A tuple containing:
|
|
1130
|
+
- merged_df : pd.DataFrame
|
|
1131
|
+
DataFrame with merged topics.
|
|
1132
|
+
- merge_history : pd.DataFrame
|
|
1133
|
+
DataFrame containing the history of merged topics.
|
|
1134
|
+
- new_topics : pd.DataFrame
|
|
1135
|
+
DataFrame containing new topics that weren't merged.
|
|
1136
|
+
|
|
1137
|
+
Notes
|
|
1138
|
+
-----
|
|
1139
|
+
This function computes cosine similarities between topic embeddings and merges
|
|
1140
|
+
topics that exceed the minimum similarity threshold. Topics that don't meet the
|
|
1141
|
+
threshold are added as new topics.
|
|
1142
|
+
"""
|
|
1143
|
+
merged_df = df1.copy()
|
|
1144
|
+
merge_history = []
|
|
1145
|
+
|
|
1146
|
+
embeddings1 = np.stack(df1["Embedding"].values)
|
|
1147
|
+
embeddings2 = np.stack(df2["Embedding"].values)
|
|
1148
|
+
|
|
1149
|
+
similarities = cosine_similarity(embeddings1, embeddings2)
|
|
1150
|
+
max_similarities = np.max(similarities, axis=0)
|
|
1151
|
+
max_similar_topics = df1["Topic"].values[np.argmax(similarities, axis=0)]
|
|
1152
|
+
|
|
1153
|
+
new_topics_mask = max_similarities < min_similarity
|
|
1154
|
+
new_topics_data = df2[new_topics_mask].copy()
|
|
1155
|
+
new_topics_data["Topic"] = np.arange(
|
|
1156
|
+
merged_df["Topic"].max() + 1,
|
|
1157
|
+
merged_df["Topic"].max() + 1 + len(new_topics_data),
|
|
1158
|
+
)
|
|
1159
|
+
new_topics_data["Timestamp"] = timestamp
|
|
1160
|
+
|
|
1161
|
+
merged_df = pd.concat([merged_df, new_topics_data], ignore_index=True)
|
|
1162
|
+
new_topics = new_topics_data.copy()
|
|
1163
|
+
|
|
1164
|
+
merge_topics_mask = max_similarities >= min_similarity
|
|
1165
|
+
merge_topics_data = df2[merge_topics_mask]
|
|
1166
|
+
|
|
1167
|
+
for max_similar_topic, group in merge_topics_data.groupby(
|
|
1168
|
+
max_similar_topics[merge_topics_mask]
|
|
1169
|
+
):
|
|
1170
|
+
similar_row = df1[df1["Topic"] == max_similar_topic].iloc[0]
|
|
1171
|
+
index = merged_df[merged_df["Topic"] == max_similar_topic].index[0]
|
|
1172
|
+
|
|
1173
|
+
merged_df.at[index, "Count"] += group["Count"].sum()
|
|
1174
|
+
merged_df.at[index, "Document_Count"] += group["Document_Count"].sum()
|
|
1175
|
+
|
|
1176
|
+
# Update the 'Documents' field with only the new documents from the current timestamp
|
|
1177
|
+
new_documents = [doc for docs in group["Documents"] for doc in docs]
|
|
1178
|
+
merged_df.at[index, "Documents"] = similar_row["Documents"] + [
|
|
1179
|
+
(timestamp, new_documents)
|
|
1180
|
+
]
|
|
1181
|
+
|
|
1182
|
+
merged_df.at[index, "Sources"] += [
|
|
1183
|
+
source for sources in group["Sources"] for source in sources
|
|
1184
|
+
]
|
|
1185
|
+
merged_df.at[index, "URLs"] += [url for urls in group["URLs"] for url in urls]
|
|
1186
|
+
|
|
1187
|
+
merge_history.extend(
|
|
1188
|
+
{
|
|
1189
|
+
"Timestamp": timestamp,
|
|
1190
|
+
"Topic1": max_similar_topic,
|
|
1191
|
+
"Topic2": row["Topic"],
|
|
1192
|
+
"Representation1": similar_row["Representation"],
|
|
1193
|
+
"Representation2": row["Representation"],
|
|
1194
|
+
"Embedding1": similar_row["Embedding"],
|
|
1195
|
+
"Embedding2": row["Embedding"],
|
|
1196
|
+
"Similarity": max_similarities[row["Topic"]],
|
|
1197
|
+
"Count1": len(similar_row["Documents"]),
|
|
1198
|
+
"Count2": len(row["Documents"]),
|
|
1199
|
+
"Document_Count1": similar_row["Document_Count"],
|
|
1200
|
+
"Document_Count2": row["Document_Count"],
|
|
1201
|
+
"Documents1": similar_row["Documents"],
|
|
1202
|
+
"Documents2": row["Documents"],
|
|
1203
|
+
"Source1": similar_row["Sources"],
|
|
1204
|
+
"Source2": row["Sources"],
|
|
1205
|
+
"URLs1": similar_row["URLs"],
|
|
1206
|
+
"URLs2": row["URLs"],
|
|
1207
|
+
}
|
|
1208
|
+
for _, row in group.iterrows()
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
return merged_df, pd.DataFrame(merge_history), new_topics
|