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.
@@ -0,0 +1,3 @@
1
+ """topiclayers — from posts to topical multilayer networks."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,7 @@
1
+ """Input adapters: convert various formats into a list of Post dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .registry import AdapterRegistry, resolve_adapter
6
+
7
+ __all__ = ["AdapterRegistry", "resolve_adapter"]
@@ -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}")