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
topiclayers/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from topiclayers.adapters.registry import register
|
|
9
|
+
from topiclayers.schema import InteractionType, Post
|
|
10
|
+
|
|
11
|
+
_INTERACTION_MAP = {
|
|
12
|
+
"original": None,
|
|
13
|
+
"repost": InteractionType.REPOST,
|
|
14
|
+
"quote": InteractionType.QUOTE,
|
|
15
|
+
"reply": InteractionType.REPLY,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_interaction(value) -> InteractionType | None:
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(value, float) and pd.isna(value):
|
|
23
|
+
return None
|
|
24
|
+
s = str(value).strip().lower()
|
|
25
|
+
if not s or s in ("nan", "none", ""):
|
|
26
|
+
return None
|
|
27
|
+
return _INTERACTION_MAP.get(s, None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_mentions(value) -> list[str]:
|
|
31
|
+
if value is None:
|
|
32
|
+
return []
|
|
33
|
+
if isinstance(value, float) and pd.isna(value):
|
|
34
|
+
return []
|
|
35
|
+
s = str(value).strip()
|
|
36
|
+
if not s or s.lower() in ("nan", "none"):
|
|
37
|
+
return []
|
|
38
|
+
return [m.strip() for m in s.split(";") if m.strip()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _row_to_post(row, extra_cols: list[str]) -> Post:
|
|
42
|
+
extra = {}
|
|
43
|
+
for col in extra_cols:
|
|
44
|
+
val = row.get(col)
|
|
45
|
+
if isinstance(val, float) and pd.isna(val):
|
|
46
|
+
val = None
|
|
47
|
+
key = col.removeprefix("extra_")
|
|
48
|
+
extra[key] = val
|
|
49
|
+
|
|
50
|
+
return Post(
|
|
51
|
+
post_id=str(row["post_id"]),
|
|
52
|
+
user_id=str(row["user_id"]),
|
|
53
|
+
text=str(row.get("text", "")),
|
|
54
|
+
created_at=row.get("created_at") if not (isinstance(row.get("created_at"), float) and pd.isna(row.get("created_at"))) else None,
|
|
55
|
+
lang=row.get("lang") if not (isinstance(row.get("lang"), float) and pd.isna(row.get("lang"))) else None,
|
|
56
|
+
interaction_type=_parse_interaction(row.get("interaction_type")),
|
|
57
|
+
target_post_id=str(row["target_post_id"]) if row.get("target_post_id") and not (isinstance(row.get("target_post_id"), float) and pd.isna(row.get("target_post_id"))) else None,
|
|
58
|
+
mentions=_parse_mentions(row.get("mentions")),
|
|
59
|
+
extra=extra,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_csv(path: Path) -> List[Post]:
|
|
64
|
+
df = pd.read_csv(path)
|
|
65
|
+
required = {"post_id", "user_id", "text"}
|
|
66
|
+
missing = required - set(df.columns)
|
|
67
|
+
if missing:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"CSV file at {path} is missing required columns: {missing}. "
|
|
70
|
+
f"Required columns are: post_id, user_id, text. "
|
|
71
|
+
f"Optional: created_at, lang, interaction_type, target_post_id, target_user_id, mentions, and extra_* columns."
|
|
72
|
+
)
|
|
73
|
+
extra_cols = [c for c in df.columns if c.startswith("extra_")]
|
|
74
|
+
return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_parquet(path: Path) -> List[Post]:
|
|
78
|
+
df = pd.read_parquet(path)
|
|
79
|
+
required = {"post_id", "user_id", "text"}
|
|
80
|
+
missing = required - set(df.columns)
|
|
81
|
+
if missing:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Parquet file at {path} is missing required columns: {missing}. "
|
|
84
|
+
f"Required columns are: post_id, user_id, text."
|
|
85
|
+
)
|
|
86
|
+
extra_cols = [c for c in df.columns if c.startswith("extra_")]
|
|
87
|
+
return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
register("csv_posts", load_csv)
|
|
91
|
+
register("parquet_posts", load_parquet)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Input adapter registry — maps format strings to loader functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, List
|
|
8
|
+
|
|
9
|
+
from topiclayers.schema import Post
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
AdapterFn = Callable[[Path], List[Post]]
|
|
13
|
+
|
|
14
|
+
_registry: dict[str, AdapterFn] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(format_name: str, fn: AdapterFn) -> None:
|
|
18
|
+
_registry[format_name] = fn
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_adapter(path: Path) -> tuple[str, AdapterFn]:
|
|
22
|
+
"""Auto-detect format from file extension and return (format_name, loader)."""
|
|
23
|
+
ext = path.suffix.lower()
|
|
24
|
+
if ext in (".jsonl", ".json", ".dat"):
|
|
25
|
+
fmt = "twitter_jsonl"
|
|
26
|
+
elif ext == ".csv":
|
|
27
|
+
fmt = "csv_posts"
|
|
28
|
+
elif ext == ".parquet":
|
|
29
|
+
fmt = "parquet_posts"
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Cannot auto-detect format for extension '{ext}'. "
|
|
33
|
+
f"Supported: .jsonl, .json, .dat, .csv, .parquet. "
|
|
34
|
+
f"Use explicit `format:` in config for other formats."
|
|
35
|
+
)
|
|
36
|
+
if fmt not in _registry:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
f"No adapter registered for format '{fmt}'. "
|
|
39
|
+
f"Available adapters: {list(_registry.keys())}. "
|
|
40
|
+
f"Did you import topiclayers.adapters.{fmt.split('_')[0]}?"
|
|
41
|
+
)
|
|
42
|
+
return fmt, _registry[fmt]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AdapterRegistry:
|
|
46
|
+
"""Explicit registry for manual adapter lookups."""
|
|
47
|
+
pass
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from topiclayers.adapters.registry import register
|
|
8
|
+
from topiclayers.schema import InteractionType, Post
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _map_interaction_type(ref_type: str | None) -> InteractionType | None:
|
|
12
|
+
if ref_type == "retweeted":
|
|
13
|
+
return InteractionType.REPOST
|
|
14
|
+
elif ref_type == "quoted":
|
|
15
|
+
return InteractionType.QUOTE
|
|
16
|
+
elif ref_type == "replied_to":
|
|
17
|
+
return InteractionType.REPLY
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load(path: Path) -> List[Post]:
|
|
22
|
+
posts = []
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
for line in f:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
obj = json.loads(line)
|
|
29
|
+
|
|
30
|
+
post_id = str(obj.get("id", ""))
|
|
31
|
+
user_id = str(obj.get("author_id", ""))
|
|
32
|
+
text = obj.get("text", "")
|
|
33
|
+
created_at = obj.get("created_at")
|
|
34
|
+
lang = obj.get("lang")
|
|
35
|
+
|
|
36
|
+
ref_tweets = obj.get("referenced_tweets")
|
|
37
|
+
if ref_tweets and isinstance(ref_tweets, list) and len(ref_tweets) > 0:
|
|
38
|
+
ref = ref_tweets[0]
|
|
39
|
+
interaction_type = _map_interaction_type(ref.get("type"))
|
|
40
|
+
target_post_id = str(ref.get("id")) if ref.get("id") is not None else None
|
|
41
|
+
else:
|
|
42
|
+
interaction_type = None
|
|
43
|
+
target_post_id = None
|
|
44
|
+
|
|
45
|
+
entities = obj.get("entities", {})
|
|
46
|
+
mentions_raw = entities.get("mentions", []) if isinstance(entities, dict) else []
|
|
47
|
+
mentions = [m.get("username", "") for m in mentions_raw if isinstance(m, dict)]
|
|
48
|
+
|
|
49
|
+
extra = {
|
|
50
|
+
"conversation_id": str(obj.get("conversation_id")) if obj.get("conversation_id") is not None else None,
|
|
51
|
+
"context_annotations": obj.get("context_annotations", []),
|
|
52
|
+
"public_metrics": obj.get("public_metrics", {}),
|
|
53
|
+
"possibly_sensitive": obj.get("possibly_sensitive"),
|
|
54
|
+
"edit_history_tweet_ids": obj.get("edit_history_tweet_ids", []),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
post = Post(
|
|
58
|
+
post_id=post_id,
|
|
59
|
+
user_id=user_id,
|
|
60
|
+
text=text,
|
|
61
|
+
created_at=created_at,
|
|
62
|
+
lang=lang,
|
|
63
|
+
interaction_type=interaction_type,
|
|
64
|
+
target_post_id=target_post_id,
|
|
65
|
+
mentions=mentions,
|
|
66
|
+
extra=extra,
|
|
67
|
+
)
|
|
68
|
+
posts.append(post)
|
|
69
|
+
|
|
70
|
+
return posts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
register("twitter_jsonl", load)
|
topiclayers/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from topiclayers.config import RunConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _adapters_loaded() -> None:
|
|
11
|
+
import topiclayers.adapters.csv
|
|
12
|
+
import topiclayers.adapters.twitter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_pipeline(config: RunConfig) -> None:
|
|
16
|
+
from topiclayers.adapters.registry import resolve_adapter
|
|
17
|
+
from topiclayers.core.data import Dataset
|
|
18
|
+
from topiclayers.manifest import RunManifest
|
|
19
|
+
from topiclayers.core.topic import TopicModeler
|
|
20
|
+
from topiclayers.status import RunStatus
|
|
21
|
+
|
|
22
|
+
_adapters_loaded()
|
|
23
|
+
|
|
24
|
+
input_path = Path(config.input.posts)
|
|
25
|
+
if not input_path.exists():
|
|
26
|
+
print(f"Input file not found: {input_path}", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
config.output_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
status = RunStatus(config.output_dir, config.name)
|
|
31
|
+
status.start()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
_run_pipeline_stages(config, input_path, status)
|
|
35
|
+
except BaseException:
|
|
36
|
+
status.finish("failed")
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
|
|
41
|
+
from topiclayers.adapters.registry import resolve_adapter
|
|
42
|
+
from topiclayers.core.data import Dataset
|
|
43
|
+
from topiclayers.manifest import RunManifest
|
|
44
|
+
from topiclayers.core.topic import TopicModeler
|
|
45
|
+
|
|
46
|
+
status.stage("load")
|
|
47
|
+
fmt, loader = resolve_adapter(input_path)
|
|
48
|
+
print(f"Loading posts via '{fmt}' adapter from {input_path}")
|
|
49
|
+
posts = loader(input_path)
|
|
50
|
+
print(f" {len(posts)} posts loaded")
|
|
51
|
+
|
|
52
|
+
row_counts = {"raw_posts": len(posts)}
|
|
53
|
+
tm = None
|
|
54
|
+
|
|
55
|
+
cache_dir = config.output_dir / "cache"
|
|
56
|
+
|
|
57
|
+
status.stage("data", total=len(posts))
|
|
58
|
+
ds = Dataset(posts, n_cop=config.name, file_user=config.input.users)
|
|
59
|
+
ds.process(cache_dir=cache_dir, adapter_name=fmt, reporter=status)
|
|
60
|
+
row_counts["tweets"] = len(ds.df_tweets)
|
|
61
|
+
row_counts["original"] = len(ds.df_original)
|
|
62
|
+
row_counts["retweets"] = len(ds.df_retweets)
|
|
63
|
+
print(f" {len(ds.df_original)} original, {len(ds.df_retweets)} retweets, "
|
|
64
|
+
f"{len(ds.df_quotes)} quotes, {len(ds.df_reply)} replies")
|
|
65
|
+
|
|
66
|
+
if len(ds.df_original) == 0:
|
|
67
|
+
print(" No original posts found — skipping topic modeling. All retweets will be assigned topic -1.")
|
|
68
|
+
df_labeled = ds.df_original.copy()
|
|
69
|
+
df_labeled["topic"] = -1
|
|
70
|
+
df_labeled["topic_prob"] = None
|
|
71
|
+
row_counts["labeled_originals"] = len(df_labeled)
|
|
72
|
+
topic_dist = {"-1": len(df_labeled)}
|
|
73
|
+
else:
|
|
74
|
+
tm = TopicModeler(
|
|
75
|
+
ds.df_original,
|
|
76
|
+
embedder_name=config.topic_model.embedder,
|
|
77
|
+
path_cache=cache_dir,
|
|
78
|
+
name=config.name,
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
df_labeled = tm.get_topics(
|
|
82
|
+
min_topic_size=config.topic_model.min_topic_size,
|
|
83
|
+
nr_topics=config.topic_model.nr_topics,
|
|
84
|
+
random_state=config.topic_model.random_state,
|
|
85
|
+
reporter=status,
|
|
86
|
+
exact_probabilities=config.topic_model.exact_probabilities,
|
|
87
|
+
repro_mode=config.topic_model.repro_mode,
|
|
88
|
+
umap_init=config.topic_model.umap_init,
|
|
89
|
+
)
|
|
90
|
+
except RuntimeError as e:
|
|
91
|
+
print(f" Topic modeling failed: {e}")
|
|
92
|
+
print(" Assigning topic -1 to all originals and continuing.")
|
|
93
|
+
df_labeled = ds.df_original.copy()
|
|
94
|
+
df_labeled["topic"] = -1
|
|
95
|
+
df_labeled["topic_prob"] = None
|
|
96
|
+
row_counts["labeled_originals"] = len(df_labeled)
|
|
97
|
+
topic_counts = df_labeled["topic"].value_counts().to_dict()
|
|
98
|
+
topic_dist = {str(k): int(v) for k, v in topic_counts.items()}
|
|
99
|
+
print(f" {len(topic_dist)} topics found (including -1 outlier)")
|
|
100
|
+
|
|
101
|
+
status.stage("label")
|
|
102
|
+
if config.topic_model.label_model and tm is not None and tm.model is not None:
|
|
103
|
+
tm.label_topics(
|
|
104
|
+
model=config.topic_model.label_model,
|
|
105
|
+
api_base=config.topic_model.label_api_base,
|
|
106
|
+
label_context=config.topic_model.label_context,
|
|
107
|
+
)
|
|
108
|
+
df_labeled = ds.update_df(df_labeled)
|
|
109
|
+
ds._save_labeled_dataframe(cache_dir)
|
|
110
|
+
row_counts["retweets_labeled"] = len(df_labeled)
|
|
111
|
+
|
|
112
|
+
from topiclayers.core.network import NetworkCreator
|
|
113
|
+
nw = NetworkCreator(df_labeled, config.name, config.output_dir)
|
|
114
|
+
dropped_edges = 0
|
|
115
|
+
|
|
116
|
+
status.stage("network", total=len(df_labeled))
|
|
117
|
+
network_type = config.network.type
|
|
118
|
+
if network_type == "multilayer_repost":
|
|
119
|
+
nw.create_retweet_network(reporter=status)
|
|
120
|
+
try:
|
|
121
|
+
nw.create_retweet_ml(reporter=status)
|
|
122
|
+
except ValueError as e:
|
|
123
|
+
print(f" Skipping multilayer network: {e}")
|
|
124
|
+
elif network_type == "retweet":
|
|
125
|
+
nw.create_retweet_network(reporter=status)
|
|
126
|
+
elif network_type == "ttn":
|
|
127
|
+
nw.create_ttnetwork(project=config.network.project_ttn)
|
|
128
|
+
elif network_type == "all":
|
|
129
|
+
nw.create_retweet_network(reporter=status)
|
|
130
|
+
try:
|
|
131
|
+
nw.create_retweet_ml(reporter=status)
|
|
132
|
+
except ValueError as e:
|
|
133
|
+
print(f" Skipping multilayer network: {e}")
|
|
134
|
+
nw.create_ttnetwork(project=config.network.project_ttn)
|
|
135
|
+
else:
|
|
136
|
+
print(f"Unknown network type: '{network_type}'. "
|
|
137
|
+
f"Use one of: multilayer_repost, retweet, ttn, all.", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
status.stage("manifest")
|
|
141
|
+
|
|
142
|
+
params_hash = hashlib.sha256(
|
|
143
|
+
str(config).encode()
|
|
144
|
+
).hexdigest()[:16]
|
|
145
|
+
|
|
146
|
+
manifest = RunManifest.create(
|
|
147
|
+
params={
|
|
148
|
+
"name": config.name,
|
|
149
|
+
"input_format": config.input.format,
|
|
150
|
+
"input_posts": str(config.input.posts),
|
|
151
|
+
"embedder": config.topic_model.embedder,
|
|
152
|
+
"min_topic_size": config.topic_model.min_topic_size,
|
|
153
|
+
"nr_topics": config.topic_model.nr_topics,
|
|
154
|
+
"random_state": config.topic_model.random_state,
|
|
155
|
+
"network_type": config.network.type,
|
|
156
|
+
"exact_probabilities": config.topic_model.exact_probabilities,
|
|
157
|
+
"repro_mode": config.topic_model.repro_mode,
|
|
158
|
+
"umap_init": config.topic_model.umap_init,
|
|
159
|
+
},
|
|
160
|
+
row_counts=row_counts,
|
|
161
|
+
topic_dist=topic_dist,
|
|
162
|
+
dropped=dropped_edges,
|
|
163
|
+
status="success",
|
|
164
|
+
)
|
|
165
|
+
manifest.write(config.output_dir / "run_manifest.json")
|
|
166
|
+
status.finish("success")
|
|
167
|
+
print(f"\nDone. Output in {config.output_dir.resolve()}")
|
|
168
|
+
print(f" Run manifest: {config.output_dir / 'run_manifest.json'}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main() -> None:
|
|
172
|
+
if len(sys.argv) < 3 or sys.argv[1] not in ("run", "status"):
|
|
173
|
+
print("Usage: topiclayers run <config.yaml>", file=sys.stderr)
|
|
174
|
+
print(" topiclayers status <output_dir>", file=sys.stderr)
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
if sys.argv[1] == "status":
|
|
178
|
+
from topiclayers.status import print_status
|
|
179
|
+
|
|
180
|
+
sys.exit(print_status(Path(sys.argv[2])))
|
|
181
|
+
|
|
182
|
+
config_path = Path(sys.argv[2])
|
|
183
|
+
if not config_path.exists():
|
|
184
|
+
print(f"Config file not found: {config_path}", file=sys.stderr)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
cfg = RunConfig.from_yaml(config_path)
|
|
188
|
+
run_pipeline(cfg)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|
topiclayers/config.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""YAML run configuration schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class InputConfig:
|
|
12
|
+
format: str = "twitter_jsonl"
|
|
13
|
+
posts: Path = Path(".")
|
|
14
|
+
users: Optional[Path] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TopicModelConfig:
|
|
19
|
+
embedder: str = "all-MiniLM-L6-v2"
|
|
20
|
+
min_topic_size: int = 50
|
|
21
|
+
nr_topics: str = "auto"
|
|
22
|
+
random_state: int = 42
|
|
23
|
+
umap_n_neighbors: int = 15
|
|
24
|
+
umap_n_components: int = 5
|
|
25
|
+
exact_probabilities: bool = False
|
|
26
|
+
repro_mode: str = "strict"
|
|
27
|
+
# "pca" (seeded) is deterministic and ~20x faster than "spectral"
|
|
28
|
+
# with equal or better clustering quality; see docs/UMAP_INIT_BENCHMARK.md
|
|
29
|
+
umap_init: str = "pca"
|
|
30
|
+
# LLM used to label topics (any LiteLLM model string). Requires the
|
|
31
|
+
# 'labeling' extra: pip install 'topiclayers[labeling]'. Set to "" to
|
|
32
|
+
# disable. Non-fatal: if unreachable, topics keep their keyword labels.
|
|
33
|
+
label_model: str = "ollama/qwen2.5:7b"
|
|
34
|
+
# Base URL of an OpenAI-compatible endpoint for label_model (e.g.
|
|
35
|
+
# "http://localhost:8888/v1" for a local Unsloth/llama.cpp/vLLM server).
|
|
36
|
+
# When set, prefix the model with "openai/", e.g.
|
|
37
|
+
# label_model: openai/unsloth/Qwen3.8-27B-GGUF
|
|
38
|
+
# The API key is read from LABEL_API_KEY, OPENAI_LIKE_API_KEY or
|
|
39
|
+
# OPENAI_API_KEY (in that order). Leave "" for Ollama/OpenAI defaults.
|
|
40
|
+
label_api_base: str = ""
|
|
41
|
+
# Dataset-level context for labeling ("all documents are ..."), so the LLM
|
|
42
|
+
# labels the specific subtopic instead of the general domain. Empty = auto
|
|
43
|
+
# (derived from `name` when it matches cop<number>).
|
|
44
|
+
label_context: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NetworkConfig:
|
|
49
|
+
type: str = "multilayer_repost"
|
|
50
|
+
project_ttn: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RunConfig:
|
|
55
|
+
input: InputConfig = field(default_factory=InputConfig)
|
|
56
|
+
name: str = "default"
|
|
57
|
+
topic_model: TopicModelConfig = field(default_factory=TopicModelConfig)
|
|
58
|
+
network: NetworkConfig = field(default_factory=NetworkConfig)
|
|
59
|
+
output_dir: Path = Path("./out")
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_yaml(cls, path: Path) -> "RunConfig":
|
|
63
|
+
import yaml
|
|
64
|
+
|
|
65
|
+
path = Path(path)
|
|
66
|
+
with open(path) as f:
|
|
67
|
+
raw = yaml.safe_load(f)
|
|
68
|
+
|
|
69
|
+
input_raw = dict(raw.get("input", {}))
|
|
70
|
+
# input paths are resolved relative to the config file's directory
|
|
71
|
+
for key in ("posts", "users"):
|
|
72
|
+
if input_raw.get(key) is not None:
|
|
73
|
+
p = Path(input_raw[key])
|
|
74
|
+
if not p.is_absolute():
|
|
75
|
+
p = (path.parent / p).resolve()
|
|
76
|
+
input_raw[key] = p
|
|
77
|
+
|
|
78
|
+
return cls(
|
|
79
|
+
input=InputConfig(**input_raw),
|
|
80
|
+
name=raw.get("name", "default"),
|
|
81
|
+
topic_model=TopicModelConfig(**raw.get("topic_model", {})),
|
|
82
|
+
network=NetworkConfig(**raw.get("network", {})),
|
|
83
|
+
output_dir=Path(raw.get("output_dir", "./out")),
|
|
84
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Core pipeline stages: data, topic modeling, network construction."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from topiclayers.core.data import Dataset
|
|
7
|
+
from topiclayers.core.network import NetworkCreator
|
|
8
|
+
from topiclayers.core.topic import TopicModeler
|
|
9
|
+
|
|
10
|
+
__all__ = ["Dataset", "NetworkCreator", "TopicModeler"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name == "Dataset":
|
|
15
|
+
from topiclayers.core.data import Dataset
|
|
16
|
+
|
|
17
|
+
return Dataset
|
|
18
|
+
if name == "NetworkCreator":
|
|
19
|
+
from topiclayers.core.network import NetworkCreator
|
|
20
|
+
|
|
21
|
+
return NetworkCreator
|
|
22
|
+
if name == "TopicModeler":
|
|
23
|
+
from topiclayers.core.topic import TopicModeler
|
|
24
|
+
|
|
25
|
+
return TopicModeler
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|