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.
Files changed (181) hide show
  1. bertrend/BERTopicModel.py +320 -0
  2. bertrend/BERTrend.py +1211 -0
  3. bertrend/__init__.py +59 -0
  4. bertrend/config/bertopic_default_config.toml +48 -0
  5. bertrend/config/bertrend_default_config.toml +16 -0
  6. bertrend/config/parameters.py +92 -0
  7. bertrend/config/services_default_config.toml +32 -0
  8. bertrend/demos/__init__.py +4 -0
  9. bertrend/demos/demos_utils/__init__.py +13 -0
  10. bertrend/demos/demos_utils/data_loading_component.py +267 -0
  11. bertrend/demos/demos_utils/embed_documents_component.py +52 -0
  12. bertrend/demos/demos_utils/icons.py +41 -0
  13. bertrend/demos/demos_utils/messages.py +11 -0
  14. bertrend/demos/demos_utils/parameters_component.py +162 -0
  15. bertrend/demos/demos_utils/state_utils.py +99 -0
  16. bertrend/demos/start_summarizer_app.sh +9 -0
  17. bertrend/demos/start_topic_analysis_app.sh +9 -0
  18. bertrend/demos/start_weak_signals_app.sh +9 -0
  19. bertrend/demos/summarization/__init__.py +4 -0
  20. bertrend/demos/summarization/summarizer_app.py +98 -0
  21. bertrend/demos/topic_analysis/__init__.py +4 -0
  22. bertrend/demos/topic_analysis/app.py +87 -0
  23. bertrend/demos/topic_analysis/app_utils.py +69 -0
  24. bertrend/demos/topic_analysis/demo_pages/__init__.py +4 -0
  25. bertrend/demos/topic_analysis/demo_pages/explore_topics.py +455 -0
  26. bertrend/demos/topic_analysis/demo_pages/newsletters_generation.py +178 -0
  27. bertrend/demos/topic_analysis/demo_pages/temporal_visualizations.py +573 -0
  28. bertrend/demos/topic_analysis/demo_pages/topic_visualizations.py +280 -0
  29. bertrend/demos/topic_analysis/demo_pages/training_page.py +219 -0
  30. bertrend/demos/topic_analysis/messages.py +13 -0
  31. bertrend/demos/weak_signals/__init__.py +4 -0
  32. bertrend/demos/weak_signals/app.py +510 -0
  33. bertrend/demos/weak_signals/messages.py +32 -0
  34. bertrend/demos/weak_signals/visualizations_utils.py +403 -0
  35. bertrend/llm_utils/__init__.py +4 -0
  36. bertrend/llm_utils/newsletter.css +131 -0
  37. bertrend/llm_utils/newsletter_features.py +272 -0
  38. bertrend/llm_utils/openai_client.py +235 -0
  39. bertrend/llm_utils/prompts.py +94 -0
  40. bertrend/metrics/__init__.py +4 -0
  41. bertrend/metrics/temporal_metrics_embedding.py +861 -0
  42. bertrend/metrics/topic_metrics.py +199 -0
  43. bertrend/resources/common_ngrams.json +23 -0
  44. bertrend/resources/stopwords-en.json +3 -0
  45. bertrend/resources/stopwords-fr.json +1 -0
  46. bertrend/resources/stopwords-rte.json +15 -0
  47. bertrend/services/__init__.py +4 -0
  48. bertrend/services/authentication.py +66 -0
  49. bertrend/services/embedding_client.py +154 -0
  50. bertrend/services/embedding_service.py +380 -0
  51. bertrend/services/summarizer.py +45 -0
  52. bertrend/services/summary/__init__.py +4 -0
  53. bertrend/services/summary/abstractive_summarizer.py +74 -0
  54. bertrend/services/summary/chatgpt_summarizer.py +67 -0
  55. bertrend/services/summary/extractive_summarizer.py +368 -0
  56. bertrend/services/summary/lexrank.py +126 -0
  57. bertrend/services/summary/prompts.py +43 -0
  58. bertrend/tests/__init__.py +4 -0
  59. bertrend/tests/test_abstractive_summarizer.py +140 -0
  60. bertrend/tests/test_bertrend.py +1091 -0
  61. bertrend/tests/test_cache_utils.py +111 -0
  62. bertrend/tests/test_chatgpt_summarizer.py +150 -0
  63. bertrend/tests/test_config_utils.py +54 -0
  64. bertrend/tests/test_data_loading.py +326 -0
  65. bertrend/tests/test_data_structure.py +53 -0
  66. bertrend/tests/test_embedding_client.py +546 -0
  67. bertrend/tests/test_extractive_summarizer.py +250 -0
  68. bertrend/tests/test_installation.py +183 -0
  69. bertrend/tests/test_newsletter_features.py +354 -0
  70. bertrend/tests/test_openai_client.py +210 -0
  71. bertrend/tests/test_representative_docs.py +125 -0
  72. bertrend/tests/test_summarizer.py +115 -0
  73. bertrend/tests/test_temporal_metrics_embedding.py +349 -0
  74. bertrend/tests/test_topic_description.py +159 -0
  75. bertrend/tests/test_topic_metrics.py +233 -0
  76. bertrend/tests/test_topic_model.py +285 -0
  77. bertrend/tests/test_trend_analysis_data_structure.py +174 -0
  78. bertrend/tests/test_trend_analysis_prompts.py +225 -0
  79. bertrend/tests/test_trend_analysis_visualizations.py +307 -0
  80. bertrend/tests/test_trend_analysis_weak_signals.py +598 -0
  81. bertrend/tests/test_utils_init.py +70 -0
  82. bertrend/tests/test_visualizations.py +306 -0
  83. bertrend/topic_analysis/__init__.py +4 -0
  84. bertrend/topic_analysis/data_structure.py +13 -0
  85. bertrend/topic_analysis/prompts.py +42 -0
  86. bertrend/topic_analysis/representative_docs.py +57 -0
  87. bertrend/topic_analysis/topic_description.py +66 -0
  88. bertrend/topic_analysis/visualizations.py +315 -0
  89. bertrend/trend_analysis/__init__.py +4 -0
  90. bertrend/trend_analysis/data_structure.py +78 -0
  91. bertrend/trend_analysis/prompts.py +229 -0
  92. bertrend/trend_analysis/signal_llm_template_en.html +181 -0
  93. bertrend/trend_analysis/signal_llm_template_fr.html +181 -0
  94. bertrend/trend_analysis/visualizations.py +605 -0
  95. bertrend/trend_analysis/weak_signals.py +425 -0
  96. bertrend/utils/__init__.py +31 -0
  97. bertrend/utils/cache_utils.py +31 -0
  98. bertrend/utils/config_utils.py +41 -0
  99. bertrend/utils/data_loading.py +329 -0
  100. bertrend-0.3.1.dist-info/AUTHORS.txt +15 -0
  101. bertrend-0.3.1.dist-info/LICENSE.md +373 -0
  102. bertrend-0.3.1.dist-info/METADATA +400 -0
  103. bertrend-0.3.1.dist-info/RECORD +181 -0
  104. bertrend-0.3.1.dist-info/WHEEL +4 -0
  105. bertrend_apps/__init__.py +4 -0
  106. bertrend_apps/common/__init__.py +4 -0
  107. bertrend_apps/common/crontab_utils.py +113 -0
  108. bertrend_apps/common/mail_utils.py +91 -0
  109. bertrend_apps/config/feeds/arxiv_feed.toml +15 -0
  110. bertrend_apps/config/feeds/decarbonation_feed.toml +15 -0
  111. bertrend_apps/config/feeds/llm_en_feed.toml +15 -0
  112. bertrend_apps/config/feeds/llm_feed.toml +16 -0
  113. bertrend_apps/config/feeds/nlp_feed.toml +15 -0
  114. bertrend_apps/config/feeds/rte_feed.toml +15 -0
  115. bertrend_apps/config/gmail_credentials.json +1 -0
  116. bertrend_apps/config/newsletters/arxiv_newsletter_from_last.toml +39 -0
  117. bertrend_apps/config/newsletters/arxiv_newsletter_from_scratch.toml +39 -0
  118. bertrend_apps/config/newsletters/decarbonation_newsletter_from_last.toml +38 -0
  119. bertrend_apps/config/newsletters/decarbonation_newsletter_from_scratch.toml +38 -0
  120. bertrend_apps/config/newsletters/llm_en_newsletter_from_last.toml +38 -0
  121. bertrend_apps/config/newsletters/llm_newsletter_from_last.toml +39 -0
  122. bertrend_apps/config/newsletters/nlp_newsletter_from_last.toml +38 -0
  123. bertrend_apps/config/newsletters/rte_newsletter_from_last.toml +38 -0
  124. bertrend_apps/config/newsletters/rte_newsletter_from_scratch.toml +38 -0
  125. bertrend_apps/data_provider/__init__.py +9 -0
  126. bertrend_apps/data_provider/__main__.py +237 -0
  127. bertrend_apps/data_provider/arxiv_provider.py +161 -0
  128. bertrend_apps/data_provider/bing_news_provider.py +82 -0
  129. bertrend_apps/data_provider/curebot_provider.py +105 -0
  130. bertrend_apps/data_provider/data_provider.py +194 -0
  131. bertrend_apps/data_provider/google_news_provider.py +94 -0
  132. bertrend_apps/data_provider/newscatcher_provider.py +74 -0
  133. bertrend_apps/data_provider/utils.py +165 -0
  134. bertrend_apps/exploration/__init__.py +4 -0
  135. bertrend_apps/exploration/curebot/__init__.py +4 -0
  136. bertrend_apps/exploration/curebot/app.py +45 -0
  137. bertrend_apps/exploration/curebot/app_utils.py +317 -0
  138. bertrend_apps/exploration/curebot/config.toml +33 -0
  139. bertrend_apps/exploration/curebot/import_extraction_article.ipynb +108 -0
  140. bertrend_apps/exploration/curebot/old_veille_analyse.py +298 -0
  141. bertrend_apps/exploration/curebot/prompts.py +24 -0
  142. bertrend_apps/exploration/curebot/start_newsletter_generator.sh +8 -0
  143. bertrend_apps/exploration/curebot/tabs/tab1.py +150 -0
  144. bertrend_apps/exploration/curebot/tabs/tab2.py +95 -0
  145. bertrend_apps/exploration/curebot/tabs/tab3.py +125 -0
  146. bertrend_apps/exploration/dataviz/Main_page.py +10 -0
  147. bertrend_apps/exploration/dataviz/__init__.py +4 -0
  148. bertrend_apps/exploration/dataviz/pages/1_Data_feeds.py +105 -0
  149. bertrend_apps/exploration/geolocalization/__init__.py +4 -0
  150. bertrend_apps/exploration/geolocalization/entity_utils.py +118 -0
  151. bertrend_apps/exploration/geolocalization/spacy/__init__.py +4 -0
  152. bertrend_apps/exploration/geolocalization/spacy/common_factory.py +142 -0
  153. bertrend_apps/exploration/geolocalization/spacy/dbpedia.py +206 -0
  154. bertrend_apps/exploration/geolocalization/spacy/nlp.config +37 -0
  155. bertrend_apps/exploration/geolocalization/spacy/utils.py +39 -0
  156. bertrend_apps/install_feeds_and_newsletters.py +29 -0
  157. bertrend_apps/newsletters/__init__.py +4 -0
  158. bertrend_apps/newsletters/__main__.py +277 -0
  159. bertrend_apps/prospective_demo/.streamlit/config.toml +3 -0
  160. bertrend_apps/prospective_demo/.streamlit/secrets.toml +9 -0
  161. bertrend_apps/prospective_demo/__init__.py +70 -0
  162. bertrend_apps/prospective_demo/app.py +111 -0
  163. bertrend_apps/prospective_demo/authentication.py +44 -0
  164. bertrend_apps/prospective_demo/dashboard_analysis.py +132 -0
  165. bertrend_apps/prospective_demo/dashboard_common.py +79 -0
  166. bertrend_apps/prospective_demo/dashboard_signals.py +226 -0
  167. bertrend_apps/prospective_demo/feeds_common.py +38 -0
  168. bertrend_apps/prospective_demo/feeds_config.py +261 -0
  169. bertrend_apps/prospective_demo/feeds_data.py +95 -0
  170. bertrend_apps/prospective_demo/i18n.py +136 -0
  171. bertrend_apps/prospective_demo/llm_utils.py +34 -0
  172. bertrend_apps/prospective_demo/models_info.py +361 -0
  173. bertrend_apps/prospective_demo/perf_utils.py +23 -0
  174. bertrend_apps/prospective_demo/process_new_data.py +348 -0
  175. bertrend_apps/prospective_demo/report_generation.py +214 -0
  176. bertrend_apps/prospective_demo/report_template.html +148 -0
  177. bertrend_apps/prospective_demo/report_utils.py +132 -0
  178. bertrend_apps/prospective_demo/start_demo.sh +9 -0
  179. bertrend_apps/prospective_demo/streamlit_utils.py +37 -0
  180. bertrend_apps/start_apps.sh +15 -0
  181. 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