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/core/data.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import pickle
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from topiclayers.support.cache import cache_key
|
|
13
|
+
from topiclayers.schema import InteractionType, Post
|
|
14
|
+
|
|
15
|
+
_INTERACTION_REVERSE_MAP = {
|
|
16
|
+
InteractionType.REPOST: "retweeted",
|
|
17
|
+
InteractionType.QUOTE: "quoted",
|
|
18
|
+
InteractionType.REPLY: "replied_to",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ref_type(post: Post) -> str | None:
|
|
23
|
+
if post.interaction_type is None:
|
|
24
|
+
return None
|
|
25
|
+
return _INTERACTION_REVERSE_MAP.get(post.interaction_type, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Dataset:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
posts: list[Post],
|
|
32
|
+
n_cop: str = "dataset",
|
|
33
|
+
file_user: Optional[Path] = None,
|
|
34
|
+
load_users: bool = True,
|
|
35
|
+
):
|
|
36
|
+
self.posts = posts
|
|
37
|
+
self.n_cop = n_cop
|
|
38
|
+
self.file_user = Path(file_user) if file_user is not None else None
|
|
39
|
+
self.load_users = load_users
|
|
40
|
+
self.name = n_cop
|
|
41
|
+
|
|
42
|
+
self.df_tweets: Optional[pd.DataFrame] = None
|
|
43
|
+
self.df_users: Optional[pd.DataFrame] = None
|
|
44
|
+
self.df_original: Optional[pd.DataFrame] = None
|
|
45
|
+
self.df_retweets: Optional[pd.DataFrame] = None
|
|
46
|
+
self.df_quotes: Optional[pd.DataFrame] = None
|
|
47
|
+
self.df_reply: Optional[pd.DataFrame] = None
|
|
48
|
+
self.df_retweets_labeled: Optional[pd.DataFrame] = None
|
|
49
|
+
|
|
50
|
+
def process(
|
|
51
|
+
self,
|
|
52
|
+
cache_dir: Optional[Path] = None,
|
|
53
|
+
adapter_name: str = "unknown",
|
|
54
|
+
reporter=None,
|
|
55
|
+
) -> None:
|
|
56
|
+
cache_dir = Path(cache_dir) if cache_dir is not None else Path("./cache")
|
|
57
|
+
data_dir = cache_dir / "data"
|
|
58
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
tweets_pkl = data_dir / f"tweets_{self.name}.pkl"
|
|
61
|
+
users_pkl = data_dir / f"users_{self.name}.pkl"
|
|
62
|
+
manifest_path = data_dir / f"manifest_{self.name}.json"
|
|
63
|
+
|
|
64
|
+
posts_hash = hashlib.sha256(
|
|
65
|
+
json.dumps([p.post_id for p in self.posts], sort_keys=True).encode()
|
|
66
|
+
).hexdigest()[:16]
|
|
67
|
+
expected_key = cache_key(posts_hash, adapter_name, self.n_cop)
|
|
68
|
+
|
|
69
|
+
cache_valid = False
|
|
70
|
+
if manifest_path.exists():
|
|
71
|
+
try:
|
|
72
|
+
with open(manifest_path) as f:
|
|
73
|
+
manifest = json.load(f)
|
|
74
|
+
if manifest.get("key") == expected_key:
|
|
75
|
+
cache_valid = True
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
if cache_valid and tweets_pkl.exists():
|
|
80
|
+
self.df_tweets = pd.read_pickle(tweets_pkl)
|
|
81
|
+
if users_pkl.exists():
|
|
82
|
+
self.df_users = pd.read_pickle(users_pkl)
|
|
83
|
+
else:
|
|
84
|
+
if tweets_pkl.exists():
|
|
85
|
+
print("Cache key mismatch — regenerating data cache.")
|
|
86
|
+
users_dict = self._load_user_dict() if self.file_user is not None else {}
|
|
87
|
+
tweets_dict = {}
|
|
88
|
+
for i, post in enumerate(self.posts):
|
|
89
|
+
if reporter is not None:
|
|
90
|
+
reporter.update(i + 1)
|
|
91
|
+
author_name = None
|
|
92
|
+
if users_dict and post.user_id in users_dict:
|
|
93
|
+
author_name = users_dict[post.user_id].get("username")
|
|
94
|
+
|
|
95
|
+
mentions_id_raw = post.extra.get("mentions_id", []) if isinstance(post.extra, dict) else []
|
|
96
|
+
mentions_id = [str(m) for m in mentions_id_raw] if mentions_id_raw else []
|
|
97
|
+
mentions_name = [str(m) for m in post.mentions] if post.mentions else []
|
|
98
|
+
|
|
99
|
+
tweets_dict[post.post_id] = {
|
|
100
|
+
"author": post.user_id,
|
|
101
|
+
"author_name": author_name,
|
|
102
|
+
"text": post.text,
|
|
103
|
+
"date": post.created_at,
|
|
104
|
+
"lang": post.lang,
|
|
105
|
+
"conversation_id": post.extra.get("conversation_id") if isinstance(post.extra, dict) else None,
|
|
106
|
+
"referenced_type": _ref_type(post),
|
|
107
|
+
"referenced_id": post.target_post_id,
|
|
108
|
+
"mentions_name": mentions_name,
|
|
109
|
+
"mentions_id": mentions_id,
|
|
110
|
+
"attachments": post.extra.get("attachments") if isinstance(post.extra, dict) else None,
|
|
111
|
+
"cop": self.n_cop,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
self.df_tweets = pd.DataFrame(tweets_dict).T
|
|
115
|
+
self.df_tweets.index.name = "id"
|
|
116
|
+
|
|
117
|
+
if users_dict:
|
|
118
|
+
self.df_users = pd.DataFrame(users_dict).T
|
|
119
|
+
self.df_users.to_csv(data_dir / f"users_{self.name}.csv")
|
|
120
|
+
self.df_users.to_pickle(users_pkl)
|
|
121
|
+
|
|
122
|
+
self.df_tweets.to_csv(data_dir / f"tweets_{self.name}.csv")
|
|
123
|
+
self.df_tweets.to_pickle(tweets_pkl)
|
|
124
|
+
|
|
125
|
+
manifest = {
|
|
126
|
+
"key": expected_key,
|
|
127
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
128
|
+
"n_cop": self.n_cop,
|
|
129
|
+
"n_rows": len(self.df_tweets),
|
|
130
|
+
"n_users": len(self.df_users) if self.df_users is not None else 0,
|
|
131
|
+
"adapter": adapter_name,
|
|
132
|
+
}
|
|
133
|
+
with open(manifest_path, "w") as f:
|
|
134
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
135
|
+
|
|
136
|
+
self._create_dataframes()
|
|
137
|
+
|
|
138
|
+
def _load_user_dict(self) -> dict:
|
|
139
|
+
import json
|
|
140
|
+
|
|
141
|
+
if self.file_user is None:
|
|
142
|
+
return {}
|
|
143
|
+
if not self.file_user.exists():
|
|
144
|
+
print(f"User file not found: {self.file_user} — skipping user metadata.")
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
users = {}
|
|
148
|
+
with open(self.file_user) as f:
|
|
149
|
+
for line in f:
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line:
|
|
152
|
+
continue
|
|
153
|
+
obj = json.loads(line)
|
|
154
|
+
uid = str(obj.get("id", ""))
|
|
155
|
+
users[uid] = {
|
|
156
|
+
"username": obj.get("username", ""),
|
|
157
|
+
"tweet_count": obj.get("public_metrics", {}).get("tweet_count", 0),
|
|
158
|
+
"followers": obj.get("public_metrics", {}).get("followers_count", 0),
|
|
159
|
+
"following": obj.get("public_metrics", {}).get("following_count", 0),
|
|
160
|
+
}
|
|
161
|
+
return users
|
|
162
|
+
|
|
163
|
+
def _create_dataframes(self) -> None:
|
|
164
|
+
df = self.df_tweets
|
|
165
|
+
self.df_original = df[df["referenced_type"].isna()].copy()
|
|
166
|
+
self.df_retweets = df[df["referenced_type"] == "retweeted"].copy()
|
|
167
|
+
self.df_quotes = df[df["referenced_type"] == "quoted"].copy()
|
|
168
|
+
self.df_reply = df[df["referenced_type"] == "replied_to"].copy()
|
|
169
|
+
|
|
170
|
+
def update_df(self, df_labeled: pd.DataFrame) -> pd.DataFrame:
|
|
171
|
+
if "id" in df_labeled.columns and df_labeled.index.name != "id":
|
|
172
|
+
df_labeled = df_labeled.set_index("id")
|
|
173
|
+
|
|
174
|
+
df_retweets_copy = self.df_retweets.copy()
|
|
175
|
+
df_retweets_copy["topic"] = df_retweets_copy["referenced_id"]
|
|
176
|
+
self.df_retweets_labeled = pd.concat([df_labeled, df_retweets_copy])
|
|
177
|
+
|
|
178
|
+
topic_dict = self.df_retweets_labeled["topic"].to_dict()
|
|
179
|
+
|
|
180
|
+
resolved: dict = {}
|
|
181
|
+
unresolved_count = 0
|
|
182
|
+
for idx, value in topic_dict.items():
|
|
183
|
+
visited: set = set()
|
|
184
|
+
current = idx
|
|
185
|
+
val = value
|
|
186
|
+
while isinstance(val, str):
|
|
187
|
+
if val in visited:
|
|
188
|
+
val = None
|
|
189
|
+
unresolved_count += 1
|
|
190
|
+
break
|
|
191
|
+
visited.add(val)
|
|
192
|
+
if val not in topic_dict:
|
|
193
|
+
unresolved_count += 1
|
|
194
|
+
val = None
|
|
195
|
+
break
|
|
196
|
+
val = topic_dict[val]
|
|
197
|
+
resolved[idx] = val
|
|
198
|
+
|
|
199
|
+
self.df_retweets_labeled["topic"] = self.df_retweets_labeled.index.map(resolved)
|
|
200
|
+
print(f"Unresolved references: {unresolved_count}")
|
|
201
|
+
if unresolved_count > 0:
|
|
202
|
+
print(f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
|
|
203
|
+
|
|
204
|
+
self.df_retweets_labeled = self.df_retweets_labeled[
|
|
205
|
+
self.df_retweets_labeled["topic"].apply(lambda x: not isinstance(x, str))
|
|
206
|
+
]
|
|
207
|
+
self.df_retweets_labeled = self.df_retweets_labeled[self.df_retweets_labeled["topic"].notna()]
|
|
208
|
+
|
|
209
|
+
return self.df_retweets_labeled
|
|
210
|
+
|
|
211
|
+
def _save_labeled_dataframe(self, cache_dir: Optional[Path] = None) -> None:
|
|
212
|
+
cache_dir = Path(cache_dir) if cache_dir is not None else Path("./cache")
|
|
213
|
+
data_dir = cache_dir / "data"
|
|
214
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
labeled_file = data_dir / f"retweet_labeled_{self.name}"
|
|
216
|
+
self.df_retweets_labeled.to_csv(f"{labeled_file}.csv")
|
|
217
|
+
self.df_retweets_labeled.to_pickle(f"{labeled_file}.pkl")
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import networkx as nx
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NetworkCreator:
|
|
10
|
+
def __init__(self, df: pd.DataFrame, name: str, output_dir: Path):
|
|
11
|
+
self.df = df
|
|
12
|
+
self.name = name
|
|
13
|
+
self.output_dir = Path(output_dir)
|
|
14
|
+
self.graph_dir = self.output_dir / "networks"
|
|
15
|
+
self.proj_graphs: dict = {}
|
|
16
|
+
self.ml_network = None
|
|
17
|
+
|
|
18
|
+
def create_retweet_network(self, reporter=None) -> nx.DiGraph:
|
|
19
|
+
edges, missing_refs = self._aggregate_edges(self.df, reporter=reporter)
|
|
20
|
+
|
|
21
|
+
G = nx.DiGraph()
|
|
22
|
+
G.add_nodes_from(self.df["author"].unique())
|
|
23
|
+
G.add_weighted_edges_from(
|
|
24
|
+
(src, tgt, int(w)) for src, tgt, w in edges.itertuples(index=False, name=None)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if missing_refs:
|
|
28
|
+
print(f" {missing_refs} retweets reference tweets not in dataset — skipped.")
|
|
29
|
+
|
|
30
|
+
self._save_graph(G, "retweet")
|
|
31
|
+
return G
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _aggregate_edges(df: pd.DataFrame, reporter=None) -> tuple[pd.DataFrame, int]:
|
|
35
|
+
"""Aggregate retweet rows into unique (author, target_author, weight) edges.
|
|
36
|
+
|
|
37
|
+
Returns the aggregated edge frame and the number of retweet rows whose
|
|
38
|
+
referenced tweet is not present in ``df`` (unresolvable target).
|
|
39
|
+
"""
|
|
40
|
+
if reporter is not None:
|
|
41
|
+
reporter.update(0, total=len(df))
|
|
42
|
+
|
|
43
|
+
rt = df.loc[df["referenced_id"].notna(), ["author", "referenced_id"]]
|
|
44
|
+
targets = rt["referenced_id"].map(df["author"])
|
|
45
|
+
missing_refs = int(targets.isna().sum())
|
|
46
|
+
|
|
47
|
+
edges = (
|
|
48
|
+
pd.DataFrame({"author": rt["author"], "target": targets})
|
|
49
|
+
.dropna(subset=["target"])
|
|
50
|
+
.groupby(["author", "target"])
|
|
51
|
+
.size()
|
|
52
|
+
.reset_index(name="weight")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if reporter is not None:
|
|
56
|
+
reporter.update(len(df), total=len(df))
|
|
57
|
+
return edges, missing_refs
|
|
58
|
+
|
|
59
|
+
def create_retweet_ml(self, reporter=None):
|
|
60
|
+
import uunet.multinet as ml
|
|
61
|
+
|
|
62
|
+
topics = self.df["topic"].unique()
|
|
63
|
+
topics = topics[topics != -1]
|
|
64
|
+
|
|
65
|
+
if len(topics) == 0:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Cannot create multilayer network: all topics are -1 (outliers). "
|
|
68
|
+
"This means BERTopic found no topic clusters. "
|
|
69
|
+
"Try: <1> reduce min_topic_size, <2> use a larger dataset, "
|
|
70
|
+
"<3> check the 'text' column is not empty."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
ml_network = ml.empty()
|
|
74
|
+
dropped_edges_total = 0
|
|
75
|
+
|
|
76
|
+
rt = self.df.loc[
|
|
77
|
+
self.df["referenced_id"].notna(), ["author", "referenced_id", "topic"]
|
|
78
|
+
]
|
|
79
|
+
total_work = int((self.df["topic"] != -1).sum())
|
|
80
|
+
done = 0
|
|
81
|
+
|
|
82
|
+
for topic, df_tmp in self.df.groupby("topic"):
|
|
83
|
+
if topic == -1:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
G = nx.DiGraph()
|
|
87
|
+
G.add_nodes_from(df_tmp["author"].unique())
|
|
88
|
+
|
|
89
|
+
rt_topic = rt[rt["topic"] == topic]
|
|
90
|
+
edges, dropped = self._aggregate_slice(df_tmp, rt_topic)
|
|
91
|
+
dropped_edges_total += dropped
|
|
92
|
+
|
|
93
|
+
G.add_weighted_edges_from(
|
|
94
|
+
(src, tgt, int(w)) for src, tgt, w in edges.itertuples(index=False, name=None)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
done += len(df_tmp)
|
|
98
|
+
if reporter is not None:
|
|
99
|
+
reporter.update(done, total=total_work)
|
|
100
|
+
|
|
101
|
+
ml.add_nx_layer(ml_network, G, str(topic))
|
|
102
|
+
|
|
103
|
+
if dropped_edges_total:
|
|
104
|
+
print(f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
|
|
105
|
+
|
|
106
|
+
self._save_multilayer(ml_network)
|
|
107
|
+
self.ml_network = ml_network
|
|
108
|
+
return ml_network
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _aggregate_slice(
|
|
112
|
+
df_slice: pd.DataFrame, rt_slice: pd.DataFrame
|
|
113
|
+
) -> tuple[pd.DataFrame, int]:
|
|
114
|
+
"""Aggregate retweet rows of one topic slice into weighted edges.
|
|
115
|
+
|
|
116
|
+
Returns (edges, dropped) where ``dropped`` counts rows whose referenced
|
|
117
|
+
tweet is not present in ``df_slice``.
|
|
118
|
+
"""
|
|
119
|
+
targets = rt_slice["referenced_id"].map(df_slice["author"])
|
|
120
|
+
dropped = int(targets.isna().sum())
|
|
121
|
+
edges = (
|
|
122
|
+
pd.DataFrame({"author": rt_slice["author"], "target": targets})
|
|
123
|
+
.dropna(subset=["target"])
|
|
124
|
+
.groupby(["author", "target"])
|
|
125
|
+
.size()
|
|
126
|
+
.reset_index(name="weight")
|
|
127
|
+
)
|
|
128
|
+
return edges, dropped
|
|
129
|
+
|
|
130
|
+
def create_ttnetwork(self, project: bool = True):
|
|
131
|
+
A = self.df["author"].unique()
|
|
132
|
+
M = self.df.index.tolist()
|
|
133
|
+
x = self.df["text"].to_dict()
|
|
134
|
+
topics = self.df["topic"].to_dict()
|
|
135
|
+
author = self.df["author"].to_dict()
|
|
136
|
+
is_retweet = self.df["referenced_type"].to_dict()
|
|
137
|
+
is_retweet = {k: "original" if v is None else v for k, v in is_retweet.items()}
|
|
138
|
+
|
|
139
|
+
g = nx.DiGraph()
|
|
140
|
+
|
|
141
|
+
g.add_nodes_from(A, bipartite=0)
|
|
142
|
+
g.add_nodes_from(M, bipartite=1)
|
|
143
|
+
|
|
144
|
+
edges = list(zip(self.df["author"], self.df.index))
|
|
145
|
+
ref_edges = [
|
|
146
|
+
(idx, str(ref_id))
|
|
147
|
+
for idx, ref_id in zip(self.df.index, self.df["referenced_id"])
|
|
148
|
+
if ref_id is not None and not (isinstance(ref_id, float) and pd.isna(ref_id))
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
men_edges = []
|
|
152
|
+
if "mentions_name" in self.df.columns:
|
|
153
|
+
exploded = self.df["mentions_name"].explode().dropna()
|
|
154
|
+
exploded = exploded[exploded.map(lambda m: isinstance(m, str))]
|
|
155
|
+
men_edges = list(zip(exploded.index, exploded))
|
|
156
|
+
|
|
157
|
+
g.add_edges_from(edges, weight=10)
|
|
158
|
+
g.add_edges_from(ref_edges, weight=1)
|
|
159
|
+
|
|
160
|
+
nodes_to_remove = [node for node in g.nodes if "bipartite" not in g.nodes[node]]
|
|
161
|
+
g.remove_nodes_from(nodes_to_remove)
|
|
162
|
+
|
|
163
|
+
date_lookup = self.df["date"].to_dict()
|
|
164
|
+
|
|
165
|
+
t = {}
|
|
166
|
+
for e in g.edges():
|
|
167
|
+
if e[1] in date_lookup:
|
|
168
|
+
t[e] = date_lookup[e[1]]
|
|
169
|
+
|
|
170
|
+
g.add_edges_from(men_edges)
|
|
171
|
+
|
|
172
|
+
nodes_to_set = [node for node in g.nodes if "bipartite" not in g.nodes[node]]
|
|
173
|
+
for node in nodes_to_set:
|
|
174
|
+
g.nodes[node].setdefault("bipartite", 0)
|
|
175
|
+
|
|
176
|
+
nx.set_edge_attributes(g, t, "date")
|
|
177
|
+
nx.set_node_attributes(g, x, "text")
|
|
178
|
+
nx.set_node_attributes(g, topics, "topics")
|
|
179
|
+
nx.set_node_attributes(g, author, "author")
|
|
180
|
+
nx.set_node_attributes(g, is_retweet, "is_retweet")
|
|
181
|
+
|
|
182
|
+
for node in g.nodes():
|
|
183
|
+
if "label" not in g.nodes[node]:
|
|
184
|
+
g.nodes[node]["label"] = str(node)
|
|
185
|
+
if "author" not in g.nodes[node] and g.nodes[node].get("bipartite") == 0:
|
|
186
|
+
g.nodes[node]["author"] = str(node)
|
|
187
|
+
|
|
188
|
+
self._save_graph(g, "ttt")
|
|
189
|
+
|
|
190
|
+
if project:
|
|
191
|
+
self._project_network(g, title=self.name)
|
|
192
|
+
|
|
193
|
+
return g
|
|
194
|
+
|
|
195
|
+
def _project_network(self, nx_graph=None, path=None, title="") -> dict:
|
|
196
|
+
from igraph import Graph
|
|
197
|
+
|
|
198
|
+
def recursive_explore(graph, node, start_node, previous_node=None, edges=None, topic=None, depth=0):
|
|
199
|
+
neighbors = graph.neighborhood(node.index, mode="out")
|
|
200
|
+
|
|
201
|
+
if edges is None:
|
|
202
|
+
edges = {}
|
|
203
|
+
|
|
204
|
+
if node["bipartite"] == 0.0:
|
|
205
|
+
if depth == 2:
|
|
206
|
+
edges.setdefault(topic, []).append((start_node["label"], node["label"]))
|
|
207
|
+
return edges
|
|
208
|
+
elif depth > 2:
|
|
209
|
+
edges.setdefault(topic, []).append((start_node["label"], previous_node["author"]))
|
|
210
|
+
return edges
|
|
211
|
+
else:
|
|
212
|
+
if topic is None:
|
|
213
|
+
topic = node["topics"]
|
|
214
|
+
if len(neighbors) == 1:
|
|
215
|
+
edges.setdefault(topic, []).append((start_node["label"], node["author"]))
|
|
216
|
+
return edges
|
|
217
|
+
|
|
218
|
+
for neighbor_idx in neighbors[1:]:
|
|
219
|
+
new_node = graph.vs[neighbor_idx]
|
|
220
|
+
recursive_explore(
|
|
221
|
+
graph, node=new_node, previous_node=node,
|
|
222
|
+
start_node=start_node, depth=depth + 1, edges=edges, topic=topic,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return edges
|
|
226
|
+
|
|
227
|
+
if path is not None:
|
|
228
|
+
g = Graph.Read_GML(path)
|
|
229
|
+
elif nx_graph is not None:
|
|
230
|
+
g = Graph.from_networkx(nx_graph)
|
|
231
|
+
g.vs["label"] = [str(n) for n in nx_graph.nodes()]
|
|
232
|
+
g.vs["_nx_name"] = [str(n) for n in nx_graph.nodes()]
|
|
233
|
+
else:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
"Provide either a path to a GML file or a networkx graph. "
|
|
236
|
+
"Call create_ttnetwork(project=True) to project the bipartite TTT network."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
all_edges: dict = {}
|
|
240
|
+
|
|
241
|
+
for n in g.vs.select(bipartite_eq=0):
|
|
242
|
+
result = recursive_explore(g, n, start_node=n)
|
|
243
|
+
if result is None:
|
|
244
|
+
continue
|
|
245
|
+
for key in set(list(all_edges.keys()) + list(result.keys())):
|
|
246
|
+
existing = all_edges.get(key, [])
|
|
247
|
+
incoming = result.get(key, [])
|
|
248
|
+
if incoming is not None:
|
|
249
|
+
all_edges[key] = list(set(existing + incoming))
|
|
250
|
+
|
|
251
|
+
deduped = {e: list(set(all_edges[e])) for e in all_edges}
|
|
252
|
+
deduped.pop(None, None)
|
|
253
|
+
|
|
254
|
+
self._save_projected(deduped)
|
|
255
|
+
return self.proj_graphs
|
|
256
|
+
|
|
257
|
+
def _save_projected(self, edges: dict) -> None:
|
|
258
|
+
prj_dir = self.graph_dir / "projected"
|
|
259
|
+
prj_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
|
|
261
|
+
for t, e in edges.items():
|
|
262
|
+
t_str = str(t)
|
|
263
|
+
if t_str.lower() == "nan":
|
|
264
|
+
continue
|
|
265
|
+
g = nx.from_edgelist(e, create_using=nx.DiGraph())
|
|
266
|
+
self.proj_graphs[t_str] = g
|
|
267
|
+
nx.write_gml(g, prj_dir / f"{self.name}__prj_{t_str}.gml")
|
|
268
|
+
|
|
269
|
+
def _save_graph(self, G, title: str) -> Path:
|
|
270
|
+
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
filename = self.graph_dir / f"{self.name}_{title}.gml"
|
|
272
|
+
nx.write_gml(G, filename)
|
|
273
|
+
return filename
|
|
274
|
+
|
|
275
|
+
def _save_multilayer(self, ml_network) -> Path:
|
|
276
|
+
import uunet.multinet as ml
|
|
277
|
+
|
|
278
|
+
self.graph_dir.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
filename = self.graph_dir / f"{self.name}_retweet_network_ml.gml"
|
|
280
|
+
ml.write(ml_network, file=str(filename))
|
|
281
|
+
return filename
|