topiclayers 0.2.0__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.
- topiclayers/__init__.py +3 -0
- topiclayers/adapters/__init__.py +7 -0
- topiclayers/adapters/csv.py +91 -0
- topiclayers/adapters/registry.py +47 -0
- topiclayers/adapters/twitter.py +73 -0
- topiclayers/cli.py +192 -0
- topiclayers/config.py +84 -0
- topiclayers/core/__init__.py +26 -0
- topiclayers/core/data.py +217 -0
- topiclayers/core/network.py +281 -0
- topiclayers/core/topic.py +712 -0
- topiclayers/manifest.py +88 -0
- topiclayers/schema.py +27 -0
- topiclayers/status.py +214 -0
- topiclayers/support/__init__.py +1 -0
- topiclayers/support/cache.py +11 -0
- topiclayers/support/repro.py +16 -0
- topiclayers-0.2.0.dist-info/METADATA +201 -0
- topiclayers-0.2.0.dist-info/RECORD +22 -0
- topiclayers-0.2.0.dist-info/WHEEL +4 -0
- topiclayers-0.2.0.dist-info/entry_points.txt +3 -0
- topiclayers-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from topiclayers.support.cache import cache_key
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TopicModeler:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
df: pd.DataFrame,
|
|
20
|
+
*,
|
|
21
|
+
embedder_name: str = "all-MiniLM-L6-v2",
|
|
22
|
+
path_cache: Path = Path("./cache"),
|
|
23
|
+
name: str = "dataset",
|
|
24
|
+
):
|
|
25
|
+
self.df = df.copy()
|
|
26
|
+
self.embedder_name = embedder_name
|
|
27
|
+
self.name = name
|
|
28
|
+
self.path_cache = Path(path_cache)
|
|
29
|
+
|
|
30
|
+
self.tm_path = self.path_cache / "tm" / self.embedder_name
|
|
31
|
+
self.df_labeled_path = self.tm_path / f"tweets_{self.name}_labeled.pkl"
|
|
32
|
+
self.model_path = self.tm_path / f"model_{self.name}"
|
|
33
|
+
self.embeddings_path = self.tm_path / f"embeddings_{self.name}.pkl"
|
|
34
|
+
self.topic_path = self.tm_path / f"topics_{self.name}.csv"
|
|
35
|
+
|
|
36
|
+
self.model = None
|
|
37
|
+
self.embeddings = None
|
|
38
|
+
self.docs = None
|
|
39
|
+
|
|
40
|
+
def get_topics(
|
|
41
|
+
self,
|
|
42
|
+
min_topic_size: int = 50,
|
|
43
|
+
nr_topics: str = "auto",
|
|
44
|
+
random_state: int = 42,
|
|
45
|
+
umap_model: Optional[object] = None,
|
|
46
|
+
hdbscan_model: Optional[object] = None,
|
|
47
|
+
reporter=None,
|
|
48
|
+
exact_probabilities: bool = False,
|
|
49
|
+
repro_mode: str = "strict",
|
|
50
|
+
# "pca" (seeded) is deterministic AND ~20x faster than "spectral";
|
|
51
|
+
# pass umap_init="spectral" explicitly to restore the old behaviour
|
|
52
|
+
umap_init: str = "pca",
|
|
53
|
+
) -> pd.DataFrame:
|
|
54
|
+
from topiclayers.support.repro import set_global_seed
|
|
55
|
+
|
|
56
|
+
set_global_seed(random_state)
|
|
57
|
+
|
|
58
|
+
self.tm_path.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
manifest_path = self.tm_path / f"manifest_{self.name}.json"
|
|
61
|
+
docs_hash = _docs_hash(self.df["text"].tolist())
|
|
62
|
+
expected_key = cache_key(
|
|
63
|
+
docs_hash, self.embedder_name, str(min_topic_size),
|
|
64
|
+
str(nr_topics), str(random_state),
|
|
65
|
+
f"exact_prob={exact_probabilities}", f"repro={repro_mode}",
|
|
66
|
+
f"umap_init={umap_init}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if self.df_labeled_path.exists() and self.model_path.exists():
|
|
70
|
+
cache_valid = False
|
|
71
|
+
if manifest_path.exists():
|
|
72
|
+
try:
|
|
73
|
+
with open(manifest_path) as f:
|
|
74
|
+
manifest = json.load(f)
|
|
75
|
+
if manifest.get("key") == expected_key and manifest.get("status") == "success":
|
|
76
|
+
cache_valid = True
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
if cache_valid:
|
|
80
|
+
print("using cached topics")
|
|
81
|
+
self.df = pd.read_pickle(self.df_labeled_path)
|
|
82
|
+
from bertopic import BERTopic
|
|
83
|
+
self.model = BERTopic.load(self.model_path)
|
|
84
|
+
if "new_text" in self.df.columns:
|
|
85
|
+
self.docs = self.df["new_text"].tolist()
|
|
86
|
+
return self.df
|
|
87
|
+
|
|
88
|
+
print("running topic modeling")
|
|
89
|
+
docs = self._preprocess()
|
|
90
|
+
|
|
91
|
+
if self.embeddings_path.exists():
|
|
92
|
+
embed_manifest = self.tm_path / f"embeddings_manifest_{self.name}.json"
|
|
93
|
+
embed_key = cache_key(_docs_hash(docs), self.embedder_name)
|
|
94
|
+
use_cached = False
|
|
95
|
+
if embed_manifest.exists():
|
|
96
|
+
try:
|
|
97
|
+
with open(embed_manifest) as f:
|
|
98
|
+
em = json.load(f)
|
|
99
|
+
if em.get("key") == embed_key:
|
|
100
|
+
use_cached = True
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
if use_cached:
|
|
104
|
+
print(" loading embeddings from cache")
|
|
105
|
+
with open(self.embeddings_path, "rb") as f:
|
|
106
|
+
self.embeddings = pickle.load(f)
|
|
107
|
+
else:
|
|
108
|
+
self._get_embeddings(docs, reporter)
|
|
109
|
+
else:
|
|
110
|
+
self._get_embeddings(docs, reporter)
|
|
111
|
+
|
|
112
|
+
self._use_BERTopic(
|
|
113
|
+
docs, nr_topics, min_topic_size, random_state,
|
|
114
|
+
umap_model, hdbscan_model, reporter,
|
|
115
|
+
exact_probabilities=exact_probabilities, repro_mode=repro_mode,
|
|
116
|
+
umap_init=umap_init,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self._save_qdrant(docs, reporter)
|
|
120
|
+
self._save_files()
|
|
121
|
+
|
|
122
|
+
manifest = {
|
|
123
|
+
"key": expected_key,
|
|
124
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
125
|
+
"status": "success",
|
|
126
|
+
"min_topic_size": min_topic_size,
|
|
127
|
+
"nr_topics": nr_topics,
|
|
128
|
+
"random_state": random_state,
|
|
129
|
+
"embedder": self.embedder_name,
|
|
130
|
+
"n_rows": len(self.df),
|
|
131
|
+
}
|
|
132
|
+
with open(manifest_path, "w") as f:
|
|
133
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
134
|
+
|
|
135
|
+
return self.df
|
|
136
|
+
|
|
137
|
+
def _preprocess(self) -> list[str]:
|
|
138
|
+
self.df["new_text"] = self.df["text"]
|
|
139
|
+
self.df["new_text"] = self.df["new_text"].str.replace(r"http\S+", "", regex=True)
|
|
140
|
+
self.df["new_text"] = self.df["new_text"].str.replace(r"@\S+", "", regex=True)
|
|
141
|
+
self.df["new_text"] = self.df["new_text"].str.replace(r"\n", "", regex=True)
|
|
142
|
+
self.df["new_text"] = self.df["new_text"].str.strip()
|
|
143
|
+
self.df = self.df[self.df["new_text"] != ""]
|
|
144
|
+
self.df.reset_index(drop=False, inplace=True)
|
|
145
|
+
docs = self.df["new_text"].tolist()
|
|
146
|
+
self.docs = docs
|
|
147
|
+
return docs
|
|
148
|
+
|
|
149
|
+
def _get_embeddings(self, docs: list[str], reporter=None) -> None:
|
|
150
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
151
|
+
|
|
152
|
+
if reporter is not None:
|
|
153
|
+
reporter.stage("embed", total=len(docs))
|
|
154
|
+
|
|
155
|
+
if self.embedder_name.startswith("text-embedding"):
|
|
156
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
157
|
+
if not api_key:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"OpenAI embedder selected but OPENAI_API_KEY is not set. "
|
|
160
|
+
"Set it in a .env file or use a SentenceTransformer model like 'all-MiniLM-L6-v2' instead."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
from openai import OpenAI
|
|
164
|
+
|
|
165
|
+
client = OpenAI(api_key=api_key)
|
|
166
|
+
batch_size = 1000
|
|
167
|
+
all_embeddings = []
|
|
168
|
+
|
|
169
|
+
for i in range(0, len(docs), batch_size):
|
|
170
|
+
batch = docs[i:i + batch_size]
|
|
171
|
+
response = client.embeddings.create(input=batch, model=self.embedder_name)
|
|
172
|
+
all_embeddings.extend([np.array(d.embedding) for d in response.data])
|
|
173
|
+
if reporter is not None:
|
|
174
|
+
reporter.update(min(i + batch_size, len(docs)))
|
|
175
|
+
|
|
176
|
+
self.embeddings = np.array(all_embeddings)
|
|
177
|
+
self.embedder = None
|
|
178
|
+
else:
|
|
179
|
+
from sentence_transformers import SentenceTransformer
|
|
180
|
+
|
|
181
|
+
model = SentenceTransformer(self.embedder_name)
|
|
182
|
+
batch_size = 256
|
|
183
|
+
all_embeddings = []
|
|
184
|
+
|
|
185
|
+
for i in range(0, len(docs), batch_size):
|
|
186
|
+
batch = docs[i:i + batch_size]
|
|
187
|
+
batch_emb = model.encode(batch, show_progress_bar=False)
|
|
188
|
+
all_embeddings.append(batch_emb)
|
|
189
|
+
if reporter is not None:
|
|
190
|
+
reporter.update(min(i + batch_size, len(docs)))
|
|
191
|
+
|
|
192
|
+
self.embeddings = np.vstack(all_embeddings) if all_embeddings else np.empty((0,))
|
|
193
|
+
self.embedder = model
|
|
194
|
+
|
|
195
|
+
self.embeddings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
with open(self.embeddings_path, "wb") as f:
|
|
197
|
+
pickle.dump(self.embeddings, f)
|
|
198
|
+
|
|
199
|
+
embed_manifest = self.tm_path / f"embeddings_manifest_{self.name}.json"
|
|
200
|
+
embed_key = cache_key(_docs_hash(docs), self.embedder_name)
|
|
201
|
+
with open(embed_manifest, "w") as f:
|
|
202
|
+
json.dump({"key": embed_key, "embedder": self.embedder_name}, f)
|
|
203
|
+
|
|
204
|
+
def _use_BERTopic(
|
|
205
|
+
self,
|
|
206
|
+
docs: list[str],
|
|
207
|
+
nr_topics: str = "auto",
|
|
208
|
+
min_topic_size: int = 50,
|
|
209
|
+
random_state: int = 42,
|
|
210
|
+
umap_model: Optional[object] = None,
|
|
211
|
+
hdbscan_model: Optional[object] = None,
|
|
212
|
+
reporter=None,
|
|
213
|
+
exact_probabilities: bool = False,
|
|
214
|
+
repro_mode: str = "strict",
|
|
215
|
+
umap_init: str = "pca",
|
|
216
|
+
) -> None:
|
|
217
|
+
import random
|
|
218
|
+
|
|
219
|
+
from umap import UMAP
|
|
220
|
+
from sklearn.cluster import HDBSCAN
|
|
221
|
+
from sklearn.feature_extraction.text import CountVectorizer
|
|
222
|
+
from bertopic import BERTopic
|
|
223
|
+
from bertopic.vectorizers import ClassTfidfTransformer
|
|
224
|
+
|
|
225
|
+
if reporter is not None:
|
|
226
|
+
reporter.stage("topic_model")
|
|
227
|
+
|
|
228
|
+
np.random.seed(random_state)
|
|
229
|
+
random.seed(random_state)
|
|
230
|
+
try:
|
|
231
|
+
import torch
|
|
232
|
+
torch.manual_seed(random_state)
|
|
233
|
+
except ImportError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
if not docs or len(docs) == 0:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"No documents for topic modeling. "
|
|
239
|
+
"The DataFrame is empty after preprocessing. "
|
|
240
|
+
"This may happen if all posts have URLs/mentions as their only text. "
|
|
241
|
+
"Check the 'text' column of your input."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if len(docs) < 10:
|
|
245
|
+
vectorizer_model = CountVectorizer(stop_words="english", min_df=1, max_df=1.0)
|
|
246
|
+
else:
|
|
247
|
+
min_df_value = max(1, min(5, len(docs) // 100))
|
|
248
|
+
vectorizer_model = CountVectorizer(
|
|
249
|
+
stop_words="english", min_df=min_df_value, max_df=0.95
|
|
250
|
+
)
|
|
251
|
+
ctfidf_model = ClassTfidfTransformer(reduce_frequent_words=True)
|
|
252
|
+
|
|
253
|
+
fast = repro_mode == "fast"
|
|
254
|
+
if umap_model is None:
|
|
255
|
+
n_neighbors = min(15, max(2, len(docs) - 1))
|
|
256
|
+
n_components = min(5, len(docs) - 1)
|
|
257
|
+
if n_components + 1 >= len(docs):
|
|
258
|
+
n_components = max(1, len(docs) - 3)
|
|
259
|
+
umap_params = {
|
|
260
|
+
"n_components": n_components,
|
|
261
|
+
"n_neighbors": n_neighbors,
|
|
262
|
+
"min_dist": 0.0,
|
|
263
|
+
"metric": "cosine",
|
|
264
|
+
"init": umap_init,
|
|
265
|
+
}
|
|
266
|
+
if fast:
|
|
267
|
+
# a seeded UMAP forces single-threaded execution; fast mode
|
|
268
|
+
# trades reproducibility for parallelism
|
|
269
|
+
umap_params.update({"random_state": None, "n_jobs": -1})
|
|
270
|
+
else:
|
|
271
|
+
umap_params["random_state"] = random_state
|
|
272
|
+
umap_model = UMAP(**umap_params)
|
|
273
|
+
else:
|
|
274
|
+
fast = False
|
|
275
|
+
|
|
276
|
+
if hdbscan_model is None:
|
|
277
|
+
min_cluster_size = max(2, min_topic_size)
|
|
278
|
+
hdbscan_kwargs = {"n_jobs": -1} if fast else {}
|
|
279
|
+
hdbscan_model = HDBSCAN(
|
|
280
|
+
min_samples=max(1, min_topic_size // 5),
|
|
281
|
+
min_cluster_size=min_cluster_size,
|
|
282
|
+
**hdbscan_kwargs,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
model = BERTopic(
|
|
286
|
+
vectorizer_model=vectorizer_model,
|
|
287
|
+
ctfidf_model=ctfidf_model,
|
|
288
|
+
nr_topics=nr_topics,
|
|
289
|
+
min_topic_size=min_topic_size,
|
|
290
|
+
umap_model=umap_model,
|
|
291
|
+
hdbscan_model=hdbscan_model,
|
|
292
|
+
# exact probabilities require the full membership matrix (slow on
|
|
293
|
+
# large corpora); the default computes the probability of the
|
|
294
|
+
# assigned topic only — topics themselves are identical
|
|
295
|
+
calculate_probabilities=exact_probabilities,
|
|
296
|
+
verbose=True,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
topics, probs = model.fit_transform(docs, embeddings=self.embeddings)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
self._write_failed_manifest(min_topic_size, nr_topics, random_state, str(e))
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
f"Topic modeling failed with error: {e}. "
|
|
305
|
+
f"Try reducing min_topic_size below current val ue ({min_topic_size}). "
|
|
306
|
+
f"See docs at https://maartengr.github.io/BERTopic/faq.html"
|
|
307
|
+
) from e
|
|
308
|
+
|
|
309
|
+
unique_topics = len(set(topics))
|
|
310
|
+
if unique_topics <= 1:
|
|
311
|
+
self._write_failed_manifest(min_topic_size, nr_topics, random_state, "All topics are -1 (outlier)")
|
|
312
|
+
raise RuntimeError(
|
|
313
|
+
f"All documents assigned to topic -1 (outlier). "
|
|
314
|
+
f"This means BERTopic found no clusters. "
|
|
315
|
+
f"Common causes: "
|
|
316
|
+
f"<1> min_topic_size too high (current: {min_topic_size}) — try half of it. "
|
|
317
|
+
f"<2> dataset too small — need at least a few hundred diverse posts. "
|
|
318
|
+
f"<3> all documents are too similar. "
|
|
319
|
+
f"See docs at https://maartengr.github.io/BERTopic/faq.html"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
self.df["topic"] = topics
|
|
323
|
+
if hasattr(probs, "ndim") and probs.ndim == 2:
|
|
324
|
+
self.df["topic_prob"] = probs.max(axis=1)
|
|
325
|
+
else:
|
|
326
|
+
self.df["topic_prob"] = probs
|
|
327
|
+
|
|
328
|
+
topic_info = model.get_topic_info()
|
|
329
|
+
self.topic_path.parent.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
topic_info.to_csv(self.topic_path)
|
|
331
|
+
self.model = model
|
|
332
|
+
|
|
333
|
+
def _write_failed_manifest(self, min_topic_size: int, nr_topics: str, random_state: int, error: str) -> None:
|
|
334
|
+
self.tm_path.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
manifest_path = self.tm_path / f"manifest_{self.name}.json"
|
|
336
|
+
manifest = {
|
|
337
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
338
|
+
"status": "failed",
|
|
339
|
+
"error": error,
|
|
340
|
+
"params": {
|
|
341
|
+
"min_topic_size": min_topic_size,
|
|
342
|
+
"nr_topics": nr_topics,
|
|
343
|
+
"random_state": random_state,
|
|
344
|
+
"embedder": self.embedder_name,
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
with open(manifest_path, "w") as f:
|
|
348
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
349
|
+
|
|
350
|
+
def _save_qdrant(self, docs: list[str], reporter=None) -> None:
|
|
351
|
+
qdrant_url = os.getenv("QDRANT_URL")
|
|
352
|
+
if not qdrant_url:
|
|
353
|
+
print("Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
from qdrant_client import QdrantClient, models as qmodels
|
|
358
|
+
|
|
359
|
+
client = QdrantClient(url=qdrant_url)
|
|
360
|
+
ids = self.df.index.tolist()
|
|
361
|
+
vectors = self.embeddings.tolist()
|
|
362
|
+
topics = self.df["topic"].tolist()
|
|
363
|
+
probs = self.df["topic_prob"].tolist()
|
|
364
|
+
docs = self.docs
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
client.create_collection(
|
|
368
|
+
collection_name=self.name,
|
|
369
|
+
vectors_config=qmodels.VectorParams(
|
|
370
|
+
size=len(self.embeddings[0]),
|
|
371
|
+
distance=qmodels.Distance.COSINE,
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
points = [
|
|
378
|
+
qmodels.PointStruct(
|
|
379
|
+
id=int(idx),
|
|
380
|
+
vector=vector,
|
|
381
|
+
payload={"text": text, "topic": topic, "prob": prob},
|
|
382
|
+
)
|
|
383
|
+
for idx, vector, text, topic, prob in zip(ids, vectors, docs, topics, probs)
|
|
384
|
+
]
|
|
385
|
+
client.upload_points(self.name, points)
|
|
386
|
+
if reporter is not None:
|
|
387
|
+
reporter.update(len(points), total=len(points))
|
|
388
|
+
except Exception as e:
|
|
389
|
+
print(f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
|
|
390
|
+
print("The pipeline continues — networks and topic labels are unaffected.")
|
|
391
|
+
|
|
392
|
+
def _save_files(self) -> None:
|
|
393
|
+
self.df_labeled_path.parent.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
self.df.to_pickle(self.df_labeled_path)
|
|
395
|
+
if self.model is not None:
|
|
396
|
+
self.model.save(
|
|
397
|
+
self.model_path,
|
|
398
|
+
serialization="safetensors",
|
|
399
|
+
save_ctfidf=True,
|
|
400
|
+
save_embedding_model=self.embedder_name,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def label_topics(
|
|
404
|
+
self,
|
|
405
|
+
model: str = "ollama/qwen2.5:7b",
|
|
406
|
+
n_representative_docs: int = 3,
|
|
407
|
+
overwrite: bool = False,
|
|
408
|
+
api_base: str = "",
|
|
409
|
+
batch_size: int = 40,
|
|
410
|
+
label_context: str = "",
|
|
411
|
+
max_topics: int = 0,
|
|
412
|
+
) -> dict[int, str]:
|
|
413
|
+
"""Label topics with an LLM, batching many topics per request.
|
|
414
|
+
|
|
415
|
+
Non-fatal by design: any failure (missing litellm, unreachable Ollama,
|
|
416
|
+
provider error) prints a warning and returns {} so the pipeline can
|
|
417
|
+
continue with the c-TF-IDF labels. Chunks whose request or JSON parse
|
|
418
|
+
fails keep their keyword labels; the rest are labeled.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
model: LiteLLM model string, e.g. "ollama/qwen2.5:7b",
|
|
422
|
+
"gpt-4o-mini", "anthropic/claude-3-haiku-20240307". For a custom
|
|
423
|
+
OpenAI-compatible endpoint use "openai/<model-name>" together
|
|
424
|
+
with api_base.
|
|
425
|
+
n_representative_docs: docs per topic passed to the prompt.
|
|
426
|
+
overwrite: re-label even if labels were already generated.
|
|
427
|
+
api_base: base URL of an OpenAI-compatible endpoint (e.g.
|
|
428
|
+
"http://localhost:8888/v1"). API key resolution order:
|
|
429
|
+
LABEL_API_KEY -> OPENAI_LIKE_API_KEY -> OPENAI_API_KEY.
|
|
430
|
+
batch_size: topics per LLM call (~40 keeps prompts well within
|
|
431
|
+
small-context models while cutting request count ~40x).
|
|
432
|
+
label_context: dataset-level context sentence describing what all
|
|
433
|
+
documents have in common, so the LLM labels the *subtopic*
|
|
434
|
+
rather than the general domain. Derived from the dataset name
|
|
435
|
+
when it matches "cop<number>"; empty otherwise.
|
|
436
|
+
max_topics: label only the N largest topics (0 = all). Useful for
|
|
437
|
+
a cheap preview before running the full labeling.
|
|
438
|
+
"""
|
|
439
|
+
import http.client
|
|
440
|
+
import json as _json
|
|
441
|
+
import socket
|
|
442
|
+
|
|
443
|
+
api_base = api_base or os.getenv("LABEL_API_BASE", "")
|
|
444
|
+
|
|
445
|
+
if self.model is None:
|
|
446
|
+
print(" No fitted topic model — skipping LLM labeling.")
|
|
447
|
+
return {}
|
|
448
|
+
|
|
449
|
+
topic_info = self.model.get_topic_info()
|
|
450
|
+
if "LLM_label" in topic_info.columns and not overwrite:
|
|
451
|
+
print(" LLM labels already present — skipping (use overwrite=True).")
|
|
452
|
+
return {}
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
import litellm # noqa: F401
|
|
456
|
+
except ImportError:
|
|
457
|
+
print(
|
|
458
|
+
" LLM labeling skipped: 'litellm' is not installed.\n"
|
|
459
|
+
" Install it with: pip install 'topiclayers[labeling]'\n"
|
|
460
|
+
" Topics keep their c-TF-IDF keyword labels."
|
|
461
|
+
)
|
|
462
|
+
return {}
|
|
463
|
+
|
|
464
|
+
if model.startswith("ollama/"):
|
|
465
|
+
host = os.getenv("OLLAMA_HOST", "localhost")
|
|
466
|
+
port = int(os.getenv("OLLAMA_PORT", "11434"))
|
|
467
|
+
try:
|
|
468
|
+
conn = http.client.HTTPConnection(host, port, timeout=2)
|
|
469
|
+
conn.request("GET", "/api/tags")
|
|
470
|
+
conn.getresponse().read()
|
|
471
|
+
conn.close()
|
|
472
|
+
except (OSError, socket.timeout):
|
|
473
|
+
print(
|
|
474
|
+
f" LLM labeling skipped: Ollama not reachable at {host}:{port}.\n"
|
|
475
|
+
f" Start it with: ollama serve (then pull the model:\n"
|
|
476
|
+
f" ollama pull {model.split('/', 1)[1]})\n"
|
|
477
|
+
f" Or point TOPIC_LLM to another model (e.g. gpt-4o-mini)."
|
|
478
|
+
)
|
|
479
|
+
return {}
|
|
480
|
+
|
|
481
|
+
generator_kwargs = {}
|
|
482
|
+
if api_base:
|
|
483
|
+
api_key = (
|
|
484
|
+
os.getenv("LABEL_API_KEY")
|
|
485
|
+
or os.getenv("OPENAI_LIKE_API_KEY")
|
|
486
|
+
or os.getenv("OPENAI_API_KEY")
|
|
487
|
+
)
|
|
488
|
+
if not api_key:
|
|
489
|
+
print(
|
|
490
|
+
" LLM labeling skipped: custom api_base set but no API key found.\n"
|
|
491
|
+
" Set LABEL_API_KEY (or OPENAI_LIKE_API_KEY / OPENAI_API_KEY) in .env."
|
|
492
|
+
)
|
|
493
|
+
return {}
|
|
494
|
+
generator_kwargs = {"api_base": api_base, "api_key": api_key}
|
|
495
|
+
|
|
496
|
+
context = label_context or _derive_label_context(self.name)
|
|
497
|
+
|
|
498
|
+
# collect per-topic prompt material from the fitted model
|
|
499
|
+
entries = [] # (topic_id, keywords_str, docs_str)
|
|
500
|
+
for _, row in topic_info.iterrows():
|
|
501
|
+
tid = int(row["Topic"])
|
|
502
|
+
if tid == -1:
|
|
503
|
+
continue
|
|
504
|
+
keywords = ", ".join(list(row["Representation"])[:8])
|
|
505
|
+
docs_list = row.get("Representative_Docs")
|
|
506
|
+
if isinstance(docs_list, str):
|
|
507
|
+
try:
|
|
508
|
+
docs_list = eval(docs_list)
|
|
509
|
+
except Exception:
|
|
510
|
+
docs_list = [docs_list]
|
|
511
|
+
if not isinstance(docs_list, list):
|
|
512
|
+
docs_list = []
|
|
513
|
+
docs_list = [str(d)[:280] for d in docs_list[:n_representative_docs]]
|
|
514
|
+
docs_str = "\n".join(f" {i+1}. {d}" for i, d in enumerate(docs_list)) or " (none)"
|
|
515
|
+
entries.append((tid, keywords, docs_str))
|
|
516
|
+
|
|
517
|
+
if not entries:
|
|
518
|
+
print(" No non-outlier topics to label.")
|
|
519
|
+
return {}
|
|
520
|
+
|
|
521
|
+
if max_topics and max_topics < len(entries):
|
|
522
|
+
entries = entries[:max_topics] # topic_info is sorted by size
|
|
523
|
+
print(f" Preview mode: labeling only the {max_topics} largest topics.")
|
|
524
|
+
|
|
525
|
+
system_msg = (
|
|
526
|
+
"You annotate discussion topics from social media.\n"
|
|
527
|
+
+ (f"All documents are {context}.\n" if context else "")
|
|
528
|
+
+ (
|
|
529
|
+
"For each numbered topic below, generate ONE concise label "
|
|
530
|
+
"(max 5 words) that describes the SPECIFIC subtopic being "
|
|
531
|
+
"discussed WITHIN this general domain — never the domain itself.\n"
|
|
532
|
+
"Labels like \"climate change\", \"environment\", \"social media "
|
|
533
|
+
"discussion\" are WRONG because they describe the domain, not the "
|
|
534
|
+
"specific subtopic.\n"
|
|
535
|
+
"Write each label in the dominant language of that topic's "
|
|
536
|
+
"documents.\n"
|
|
537
|
+
"Reply ONLY with a single JSON object mapping every topic id to "
|
|
538
|
+
'its label, e.g. {"0": "...", "7": "..."} — no other text.'
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def call_chunk(chunk):
|
|
543
|
+
blocks = []
|
|
544
|
+
for tid, keywords, docs_str in chunk:
|
|
545
|
+
blocks.append(f"### Topic {tid}\nKeywords: {keywords}\nDocuments:\n{docs_str}")
|
|
546
|
+
user_msg = "\n\n".join(blocks)
|
|
547
|
+
resp = litellm.completion(
|
|
548
|
+
model=model,
|
|
549
|
+
messages=[
|
|
550
|
+
{"role": "system", "content": system_msg},
|
|
551
|
+
{"role": "user", "content": user_msg},
|
|
552
|
+
],
|
|
553
|
+
**generator_kwargs,
|
|
554
|
+
)
|
|
555
|
+
text = resp.choices[0].message.content.strip()
|
|
556
|
+
# tolerate markdown fences around the JSON object
|
|
557
|
+
if text.startswith("```"):
|
|
558
|
+
text = text.split("```")[1]
|
|
559
|
+
if text.startswith("json"):
|
|
560
|
+
text = text[4:]
|
|
561
|
+
parsed = _json.loads(text)
|
|
562
|
+
return {int(k): str(v).strip() for k, v in parsed.items()}
|
|
563
|
+
|
|
564
|
+
llm_labels: dict[int, str] = {}
|
|
565
|
+
n_chunks = (len(entries) + batch_size - 1) // batch_size
|
|
566
|
+
failed = 0
|
|
567
|
+
for i in range(0, len(entries), batch_size):
|
|
568
|
+
chunk = entries[i : i + batch_size]
|
|
569
|
+
for attempt in (1, 2):
|
|
570
|
+
try:
|
|
571
|
+
llm_labels.update(call_chunk(chunk))
|
|
572
|
+
break
|
|
573
|
+
except Exception as e:
|
|
574
|
+
# Unsloth Studio-style servers require an explicit model
|
|
575
|
+
# load; resolve the local GGUF path and load once, then retry
|
|
576
|
+
if api_base and "no model loaded" in str(e).lower():
|
|
577
|
+
_load_openai_like_model(model, api_base, generator_kwargs.get("api_key", ""))
|
|
578
|
+
continue
|
|
579
|
+
if attempt == 1:
|
|
580
|
+
print(f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
|
|
581
|
+
continue
|
|
582
|
+
failed += 1
|
|
583
|
+
print(
|
|
584
|
+
f" Batch {i // batch_size + 1}/{n_chunks} failed (non-fatal): "
|
|
585
|
+
f"{type(e).__name__}: {str(e)[:120]} — those topics keep keyword labels."
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
if not llm_labels:
|
|
589
|
+
print(" No labels generated — topics keep their c-TF-IDF keyword labels.")
|
|
590
|
+
return {}
|
|
591
|
+
|
|
592
|
+
# expose labels as an aspect column named LLM_label in get_topic_info()
|
|
593
|
+
self.model.topic_aspects_["LLM_label"] = {
|
|
594
|
+
tid: [label] for tid, label in llm_labels.items()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
info = self.model.get_topic_info()
|
|
598
|
+
labels = {}
|
|
599
|
+
for _, row in info.iterrows():
|
|
600
|
+
tid = int(row["Topic"])
|
|
601
|
+
name = row.get("LLM_label")
|
|
602
|
+
if name is None or (isinstance(name, float) and pd.isna(name)):
|
|
603
|
+
name = row["Name"]
|
|
604
|
+
if isinstance(name, (list, tuple)):
|
|
605
|
+
name = " ".join(str(x) for x in name)
|
|
606
|
+
labels[tid] = str(name)
|
|
607
|
+
|
|
608
|
+
self.topic_path.parent.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
info.to_csv(self.topic_path)
|
|
610
|
+
ok = len(llm_labels)
|
|
611
|
+
suffix = f", {failed} batch(es) failed" if failed else ""
|
|
612
|
+
print(f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
|
|
613
|
+
return labels
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _derive_label_context(name: str) -> str:
|
|
617
|
+
"""Dataset-level context sentence for LLM labeling.
|
|
618
|
+
|
|
619
|
+
Recognizes COP dataset names (cop21, cop22, ...) and describes the shared
|
|
620
|
+
domain, so the model labels the specific subtopic instead of it.
|
|
621
|
+
"""
|
|
622
|
+
import re
|
|
623
|
+
|
|
624
|
+
m = re.fullmatch(r"cop(\d+)", str(name).strip().lower())
|
|
625
|
+
if m:
|
|
626
|
+
return (
|
|
627
|
+
f"social media posts about climate change and the "
|
|
628
|
+
f"COP{m.group(1)} UN Climate Change Conference"
|
|
629
|
+
)
|
|
630
|
+
return ""
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _find_gguf_file(model_dir: str) -> str | None:
|
|
634
|
+
"""Find the main GGUF file under a model directory (largest one, skipping
|
|
635
|
+
mmproj/vision projections)."""
|
|
636
|
+
from pathlib import Path as _Path
|
|
637
|
+
|
|
638
|
+
candidates = [
|
|
639
|
+
p for p in _Path(model_dir).rglob("*.gguf")
|
|
640
|
+
if "mmproj" not in p.name.lower()
|
|
641
|
+
]
|
|
642
|
+
if not candidates:
|
|
643
|
+
return None
|
|
644
|
+
return str(max(candidates, key=lambda p: p.stat().st_size))
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _load_openai_like_model(model: str, api_base: str, api_key: str) -> bool:
|
|
648
|
+
"""Try to load a model on an Unsloth Studio-style OpenAI-compatible server.
|
|
649
|
+
|
|
650
|
+
Returns True if the model appears loaded afterwards. Best-effort only:
|
|
651
|
+
any failure returns False (labeling stays non-fatal).
|
|
652
|
+
"""
|
|
653
|
+
import json
|
|
654
|
+
import urllib.error
|
|
655
|
+
import urllib.request
|
|
656
|
+
from urllib.parse import urlparse
|
|
657
|
+
|
|
658
|
+
bare = model.split("/", 1)[1] if model.startswith("openai/") else model
|
|
659
|
+
root = api_base.rstrip("/")
|
|
660
|
+
if root.endswith("/v1"):
|
|
661
|
+
root = root[: -len("/v1")]
|
|
662
|
+
|
|
663
|
+
def request(path: str, payload=None, timeout: int = 1800):
|
|
664
|
+
req = urllib.request.Request(
|
|
665
|
+
f"{root}{path}",
|
|
666
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
667
|
+
headers={
|
|
668
|
+
"Authorization": f"Bearer {api_key}",
|
|
669
|
+
"Content-Type": "application/json",
|
|
670
|
+
},
|
|
671
|
+
method="POST" if payload is not None else "GET",
|
|
672
|
+
)
|
|
673
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
674
|
+
body = r.read().decode()
|
|
675
|
+
return json.loads(body) if body.strip().startswith(("{", "[")) else body
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
# locate the local model entry and its GGUF file
|
|
679
|
+
models = request("/api/models/local")
|
|
680
|
+
entry = next(
|
|
681
|
+
(
|
|
682
|
+
m for m in models.get("models", [])
|
|
683
|
+
if m.get("id") == bare or m.get("model_id") == bare
|
|
684
|
+
),
|
|
685
|
+
None,
|
|
686
|
+
)
|
|
687
|
+
model_path = entry.get("path") if entry else None
|
|
688
|
+
gguf = _find_gguf_file(model_path) if model_path else None
|
|
689
|
+
|
|
690
|
+
load_payload = {"model": bare}
|
|
691
|
+
if gguf:
|
|
692
|
+
load_payload["model_path"] = gguf
|
|
693
|
+
elif not model_path:
|
|
694
|
+
print(f" Model '{bare}' not found on the server — cannot auto-load.")
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
print(f" Loading model '{bare}' on the server...")
|
|
698
|
+
request("/v1/load", load_payload)
|
|
699
|
+
print(f" Model '{bare}' loaded.")
|
|
700
|
+
return True
|
|
701
|
+
except Exception as e:
|
|
702
|
+
print(f" Auto-load failed: {type(e).__name__}: {e}")
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _docs_hash(docs: list[str]) -> str:
|
|
707
|
+
import hashlib
|
|
708
|
+
|
|
709
|
+
h = hashlib.sha256()
|
|
710
|
+
for doc in sorted(docs):
|
|
711
|
+
h.update(doc.encode("utf-8", errors="replace"))
|
|
712
|
+
return h.hexdigest()[:16]
|