netpol 0.1.0__tar.gz
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.
- netpol-0.1.0/LICENSE +21 -0
- netpol-0.1.0/PKG-INFO +140 -0
- netpol-0.1.0/README.md +110 -0
- netpol-0.1.0/netpol/__init__.py +35 -0
- netpol-0.1.0/netpol/bimodality.py +64 -0
- netpol-0.1.0/netpol/config.py +59 -0
- netpol-0.1.0/netpol/edges.py +39 -0
- netpol-0.1.0/netpol/ideology.py +158 -0
- netpol-0.1.0/netpol/influencers.py +45 -0
- netpol-0.1.0/netpol/layer_result.py +33 -0
- netpol-0.1.0/netpol/pipeline.py +151 -0
- netpol-0.1.0/netpol/scoring.py +40 -0
- netpol-0.1.0/pyproject.toml +44 -0
netpol-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alessiogandelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
netpol-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netpol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure polarization in (multilayer) social networks via latent-ideology scoring and Hartigan's dip test.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: polarization,social-networks,networkx,multilayer,ideology,dip-test,correspondence-analysis
|
|
8
|
+
Author: alessiogandelli
|
|
9
|
+
Author-email: alessiogandelli99@gmail.com
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
22
|
+
Requires-Dist: diptest (>=0.8)
|
|
23
|
+
Requires-Dist: networkx (>=3.0)
|
|
24
|
+
Requires-Dist: numpy (>=1.24)
|
|
25
|
+
Requires-Dist: pandas (>=2.0)
|
|
26
|
+
Project-URL: Homepage, https://github.com/alessiogandelli/netpol
|
|
27
|
+
Project-URL: Repository, https://github.com/alessiogandelli/netpol
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# netpol
|
|
31
|
+
|
|
32
|
+
Measure polarization in (multilayer) social networks.
|
|
33
|
+
|
|
34
|
+
Given a network -- or a `dict` of per-layer networks -- `netpol` selects the
|
|
35
|
+
top influencers, scores every user on a **bipolar latent-ideology axis** via
|
|
36
|
+
correspondence analysis, and tests whether the resulting score distribution is
|
|
37
|
+
multimodal (polarized) using **Hartigan's dip test**, with optional
|
|
38
|
+
**Benjamini-Hochberg FDR correction** across layers.
|
|
39
|
+
|
|
40
|
+
The method follows Falkenberg et al. (2021) and Flamino et al. (2021).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install netpol
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For local development:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/alessiogandelli/netpol && cd netpol
|
|
52
|
+
poetry install
|
|
53
|
+
poetry run pytest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Edge convention
|
|
57
|
+
|
|
58
|
+
Fixed and non-negotiable:
|
|
59
|
+
|
|
60
|
+
> `a -> b` means **"a retweets/endorses b"**.
|
|
61
|
+
|
|
62
|
+
Pass `networkx.DiGraph`s only (undirected graphs and `MultiDiGraph`s raise
|
|
63
|
+
`TypeError`). A multilayer network is just `dict[layer_id, DiGraph]`.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import networkx as nx
|
|
69
|
+
from netpol import LatentIdeologyScorer, PolarizationConfig, analyze_layers
|
|
70
|
+
|
|
71
|
+
def layer(): # two camps, each retweeting one influencer
|
|
72
|
+
g = nx.DiGraph()
|
|
73
|
+
for i in range(100):
|
|
74
|
+
g.add_edge(f"c1_{i}", "inf_1")
|
|
75
|
+
g.add_edge(f"c2_{i}", "inf_2")
|
|
76
|
+
return g
|
|
77
|
+
|
|
78
|
+
config = PolarizationConfig(n_influencers=2, min_edges=1)
|
|
79
|
+
results = analyze_layers({"l1": layer()}, config, LatentIdeologyScorer(min_sources=1))
|
|
80
|
+
print(results["l1"].is_polarized) # True
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
See `examples/quickstart.py` for a runnable version.
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
Per layer:
|
|
88
|
+
|
|
89
|
+
1. **Select influencers** -- top `n_influencers` nodes by `in_degree`
|
|
90
|
+
(configurable) with deterministic tie-breaking.
|
|
91
|
+
2. **Build the interaction table** -- one row per edge *into* an influencer
|
|
92
|
+
(`['influencer', 'user']`), self-loops excluded.
|
|
93
|
+
3. **Score users** -- correspondence analysis maps each user to a score in
|
|
94
|
+
`[-1, 1]` on a bipolar ideology axis (the `IdeologyScorer` plug point; the
|
|
95
|
+
built-in `LatentIdeologyScorer` is deterministic).
|
|
96
|
+
4. **Test for polarization** -- Hartigan's dip test on the score distribution.
|
|
97
|
+
|
|
98
|
+
Across layers, `analyze_layers` applies Benjamini-Hochberg FDR correction to
|
|
99
|
+
the per-layer p-values and re-evaluates `is_polarized` against the adjusted
|
|
100
|
+
values.
|
|
101
|
+
|
|
102
|
+
## API
|
|
103
|
+
|
|
104
|
+
- `PolarizationConfig` -- frozen config dataclass (see `netpol/config.py`).
|
|
105
|
+
- `analyze_layer(graph, config, scorer=None)` -> `LayerResult`
|
|
106
|
+
- `analyze_layers(layers, config, scorer=None)` -> `dict[layer_id, LayerResult]`
|
|
107
|
+
- `LatentIdeologyScorer(min_sources=2, max_sources=None)` -- built-in scorer.
|
|
108
|
+
- `IdeologyScorer` -- `Protocol` to plug in your own scoring.
|
|
109
|
+
- `LayerResult` -- `layer_id`, `n_nodes`, `n_edges`, `influencers`, `scores`,
|
|
110
|
+
`dip_statistic`, `p_value`, `adjusted_p_value`, `is_polarized`, `skip_reason`.
|
|
111
|
+
|
|
112
|
+
## What this does / doesn't do (yet)
|
|
113
|
+
|
|
114
|
+
Does:
|
|
115
|
+
|
|
116
|
+
- Faithful, deterministic implementation of the latent-ideology + dip-test
|
|
117
|
+
pipeline (single-layer and multilayer).
|
|
118
|
+
- FDR correction, explicit `skip_reason` on every failure path (no silent
|
|
119
|
+
`except`), directed-graph validation, `min_edges` guardrail.
|
|
120
|
+
|
|
121
|
+
Does **not** do yet (see [`docs/DEBATES.md`](docs/DEBATES.md) for the open
|
|
122
|
+
questions and `[REVISIT]` items):
|
|
123
|
+
|
|
124
|
+
- Effect-size / separation measure paired with the dip test.
|
|
125
|
+
- Score normalization across layers for comparison.
|
|
126
|
+
- Multivariate modality testing for `ideology_dimensions > 1`.
|
|
127
|
+
- Influencer-selection scope beyond per-layer (global/hybrid), adaptive pool
|
|
128
|
+
sizing, or authority/HITS ranking.
|
|
129
|
+
|
|
130
|
+
## References
|
|
131
|
+
|
|
132
|
+
- M. Falkenberg et al., "Growing polarisation around climate change on social
|
|
133
|
+
media", arXiv:2112.12137 (2021).
|
|
134
|
+
- J. Flamino et al., "Shifting polarization and Twitter news influencers
|
|
135
|
+
between two US presidential elections", arXiv:2111.02505 (2021).
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT. See `LICENSE`.
|
|
140
|
+
|
netpol-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# netpol
|
|
2
|
+
|
|
3
|
+
Measure polarization in (multilayer) social networks.
|
|
4
|
+
|
|
5
|
+
Given a network -- or a `dict` of per-layer networks -- `netpol` selects the
|
|
6
|
+
top influencers, scores every user on a **bipolar latent-ideology axis** via
|
|
7
|
+
correspondence analysis, and tests whether the resulting score distribution is
|
|
8
|
+
multimodal (polarized) using **Hartigan's dip test**, with optional
|
|
9
|
+
**Benjamini-Hochberg FDR correction** across layers.
|
|
10
|
+
|
|
11
|
+
The method follows Falkenberg et al. (2021) and Flamino et al. (2021).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install netpol
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone https://github.com/alessiogandelli/netpol && cd netpol
|
|
23
|
+
poetry install
|
|
24
|
+
poetry run pytest
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Edge convention
|
|
28
|
+
|
|
29
|
+
Fixed and non-negotiable:
|
|
30
|
+
|
|
31
|
+
> `a -> b` means **"a retweets/endorses b"**.
|
|
32
|
+
|
|
33
|
+
Pass `networkx.DiGraph`s only (undirected graphs and `MultiDiGraph`s raise
|
|
34
|
+
`TypeError`). A multilayer network is just `dict[layer_id, DiGraph]`.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import networkx as nx
|
|
40
|
+
from netpol import LatentIdeologyScorer, PolarizationConfig, analyze_layers
|
|
41
|
+
|
|
42
|
+
def layer(): # two camps, each retweeting one influencer
|
|
43
|
+
g = nx.DiGraph()
|
|
44
|
+
for i in range(100):
|
|
45
|
+
g.add_edge(f"c1_{i}", "inf_1")
|
|
46
|
+
g.add_edge(f"c2_{i}", "inf_2")
|
|
47
|
+
return g
|
|
48
|
+
|
|
49
|
+
config = PolarizationConfig(n_influencers=2, min_edges=1)
|
|
50
|
+
results = analyze_layers({"l1": layer()}, config, LatentIdeologyScorer(min_sources=1))
|
|
51
|
+
print(results["l1"].is_polarized) # True
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
See `examples/quickstart.py` for a runnable version.
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
Per layer:
|
|
59
|
+
|
|
60
|
+
1. **Select influencers** -- top `n_influencers` nodes by `in_degree`
|
|
61
|
+
(configurable) with deterministic tie-breaking.
|
|
62
|
+
2. **Build the interaction table** -- one row per edge *into* an influencer
|
|
63
|
+
(`['influencer', 'user']`), self-loops excluded.
|
|
64
|
+
3. **Score users** -- correspondence analysis maps each user to a score in
|
|
65
|
+
`[-1, 1]` on a bipolar ideology axis (the `IdeologyScorer` plug point; the
|
|
66
|
+
built-in `LatentIdeologyScorer` is deterministic).
|
|
67
|
+
4. **Test for polarization** -- Hartigan's dip test on the score distribution.
|
|
68
|
+
|
|
69
|
+
Across layers, `analyze_layers` applies Benjamini-Hochberg FDR correction to
|
|
70
|
+
the per-layer p-values and re-evaluates `is_polarized` against the adjusted
|
|
71
|
+
values.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
- `PolarizationConfig` -- frozen config dataclass (see `netpol/config.py`).
|
|
76
|
+
- `analyze_layer(graph, config, scorer=None)` -> `LayerResult`
|
|
77
|
+
- `analyze_layers(layers, config, scorer=None)` -> `dict[layer_id, LayerResult]`
|
|
78
|
+
- `LatentIdeologyScorer(min_sources=2, max_sources=None)` -- built-in scorer.
|
|
79
|
+
- `IdeologyScorer` -- `Protocol` to plug in your own scoring.
|
|
80
|
+
- `LayerResult` -- `layer_id`, `n_nodes`, `n_edges`, `influencers`, `scores`,
|
|
81
|
+
`dip_statistic`, `p_value`, `adjusted_p_value`, `is_polarized`, `skip_reason`.
|
|
82
|
+
|
|
83
|
+
## What this does / doesn't do (yet)
|
|
84
|
+
|
|
85
|
+
Does:
|
|
86
|
+
|
|
87
|
+
- Faithful, deterministic implementation of the latent-ideology + dip-test
|
|
88
|
+
pipeline (single-layer and multilayer).
|
|
89
|
+
- FDR correction, explicit `skip_reason` on every failure path (no silent
|
|
90
|
+
`except`), directed-graph validation, `min_edges` guardrail.
|
|
91
|
+
|
|
92
|
+
Does **not** do yet (see [`docs/DEBATES.md`](docs/DEBATES.md) for the open
|
|
93
|
+
questions and `[REVISIT]` items):
|
|
94
|
+
|
|
95
|
+
- Effect-size / separation measure paired with the dip test.
|
|
96
|
+
- Score normalization across layers for comparison.
|
|
97
|
+
- Multivariate modality testing for `ideology_dimensions > 1`.
|
|
98
|
+
- Influencer-selection scope beyond per-layer (global/hybrid), adaptive pool
|
|
99
|
+
sizing, or authority/HITS ranking.
|
|
100
|
+
|
|
101
|
+
## References
|
|
102
|
+
|
|
103
|
+
- M. Falkenberg et al., "Growing polarisation around climate change on social
|
|
104
|
+
media", arXiv:2112.12137 (2021).
|
|
105
|
+
- J. Flamino et al., "Shifting polarization and Twitter news influencers
|
|
106
|
+
between two US presidential elections", arXiv:2111.02505 (2021).
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""netpol -- measure polarization in (multilayer) social networks.
|
|
2
|
+
|
|
3
|
+
Given a network (or a ``dict`` of per-layer networks), the pipeline selects
|
|
4
|
+
top influencers, scores every user on a bipolar latent-ideology axis via
|
|
5
|
+
correspondence analysis, and tests whether the resulting score distribution
|
|
6
|
+
is multimodal (polarized) using Hartigan's dip test with optional FDR
|
|
7
|
+
correction across layers.
|
|
8
|
+
|
|
9
|
+
Edge convention: ``a -> b`` means "a retweets/endorses b".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from netpol.bimodality import apply_fdr_correction, dip_test
|
|
13
|
+
from netpol.config import PolarizationConfig
|
|
14
|
+
from netpol.edges import build_influencer_edges
|
|
15
|
+
from netpol.ideology import LatentIdeologyScorer
|
|
16
|
+
from netpol.influencers import select_influencers
|
|
17
|
+
from netpol.layer_result import LayerResult
|
|
18
|
+
from netpol.pipeline import analyze_layer, analyze_layers
|
|
19
|
+
from netpol.scoring import IdeologyScorer
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PolarizationConfig",
|
|
25
|
+
"LayerResult",
|
|
26
|
+
"IdeologyScorer",
|
|
27
|
+
"LatentIdeologyScorer",
|
|
28
|
+
"select_influencers",
|
|
29
|
+
"build_influencer_edges",
|
|
30
|
+
"dip_test",
|
|
31
|
+
"apply_fdr_correction",
|
|
32
|
+
"analyze_layer",
|
|
33
|
+
"analyze_layers",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Bimodality / multimodality statistics.
|
|
2
|
+
|
|
3
|
+
Hartigan's dip test detects deviations from unimodality; this is the
|
|
4
|
+
"polarization signal" used by the pipeline. Multiple-comparisons handling
|
|
5
|
+
(Benjamini-Hochberg) is kept here as a dependency-free helper so it can be
|
|
6
|
+
applied across layers at the orchestration level.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import diptest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dip_test(scores: np.ndarray) -> tuple[float, float]:
|
|
16
|
+
"""Hartigan's dip test on a 1-D score distribution.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
scores: 1-D array of ideology scores.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
``(dip_statistic, p_value)``.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: if ``len(scores) < 4`` (too little data for a meaningful
|
|
26
|
+
dip test).
|
|
27
|
+
"""
|
|
28
|
+
scores = np.asarray(scores, dtype=float).ravel()
|
|
29
|
+
if len(scores) < 4:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"dip test requires at least 4 scores, got {len(scores)}"
|
|
32
|
+
)
|
|
33
|
+
dip, p_value = diptest.diptest(scores)
|
|
34
|
+
return float(dip), float(p_value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_fdr_correction(pvalues: dict) -> dict:
|
|
38
|
+
"""Benjamini-Hochberg FDR correction, dependency-free.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
pvalues: Mapping ``key -> raw p-value``.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Mapping ``key -> adjusted p-value``. Handles ``{}`` and single-entry
|
|
45
|
+
inputs without error.
|
|
46
|
+
"""
|
|
47
|
+
if not pvalues:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
keys = list(pvalues.keys())
|
|
51
|
+
values = np.asarray([pvalues[k] for k in keys], dtype=float)
|
|
52
|
+
|
|
53
|
+
n = len(values)
|
|
54
|
+
order = np.argsort(values)
|
|
55
|
+
adjusted = np.empty(n, dtype=float)
|
|
56
|
+
running_min = np.inf
|
|
57
|
+
for rank in range(n - 1, -1, -1):
|
|
58
|
+
idx = order[rank]
|
|
59
|
+
q = values[idx] * n / (rank + 1)
|
|
60
|
+
running_min = min(running_min, q)
|
|
61
|
+
adjusted[idx] = running_min
|
|
62
|
+
adjusted = np.minimum(adjusted, 1.0)
|
|
63
|
+
|
|
64
|
+
return {k: float(a) for k, a in zip(keys, adjusted)}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Configuration for a polarization analysis run.
|
|
2
|
+
|
|
3
|
+
A ``PolarizationConfig`` captures every decision that shapes what a run
|
|
4
|
+
counts as "polarized": how influencers are chosen, how many, the ideology
|
|
5
|
+
dimensionality, and how significance is handled. These are methodological
|
|
6
|
+
choices, not implementation details -- see ``DEBATES.md`` for the reasoning
|
|
7
|
+
and open questions behind each default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PolarizationConfig:
|
|
17
|
+
"""Immutable configuration for :func:`netpol.pipeline.analyze_layers`.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
influencer_strategy: How to rank nodes when selecting influencers.
|
|
21
|
+
One of ``"degree"`` (in + out degree) or ``"in_degree"`` (authority
|
|
22
|
+
in a retweet network: "gets retweeted a lot").
|
|
23
|
+
n_influencers: Number of top influencers to select per layer.
|
|
24
|
+
ideology_dimensions: Number of latent-ideology dimensions to compute.
|
|
25
|
+
Default 1 -- a single bipolar axis with scores in ``[-1, 1]``.
|
|
26
|
+
significance_level: p-value threshold for the dip test.
|
|
27
|
+
fdr_correction: Apply Benjamini-Hochberg correction across layers.
|
|
28
|
+
min_edges: Layers with fewer directed edges than this are skipped
|
|
29
|
+
before scoring (with a ``skip_reason``), instead of crashing.
|
|
30
|
+
exclude_layers: Layer ids to skip entirely (e.g. a "misc/no topic"
|
|
31
|
+
layer). Replaces the hard-coded ``l != -1`` of the original
|
|
32
|
+
script.
|
|
33
|
+
random_seed: Reserved for future deterministic scoring. Note: the
|
|
34
|
+
built-in scorer is already deterministic (see ``DEBATES.md``).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
influencer_strategy: str = "in_degree"
|
|
38
|
+
n_influencers: int = 30
|
|
39
|
+
ideology_dimensions: int = 1
|
|
40
|
+
significance_level: float = 0.05
|
|
41
|
+
fdr_correction: bool = True
|
|
42
|
+
min_edges: int = 10
|
|
43
|
+
exclude_layers: tuple = field(default_factory=tuple)
|
|
44
|
+
random_seed: int | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if self.influencer_strategy not in {"degree", "in_degree"}:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"influencer_strategy must be 'degree' or 'in_degree', "
|
|
50
|
+
f"got {self.influencer_strategy!r}"
|
|
51
|
+
)
|
|
52
|
+
if self.n_influencers <= 0:
|
|
53
|
+
raise ValueError("n_influencers must be > 0")
|
|
54
|
+
if self.ideology_dimensions < 1:
|
|
55
|
+
raise ValueError("ideology_dimensions must be >= 1")
|
|
56
|
+
if not 0 < self.significance_level < 1:
|
|
57
|
+
raise ValueError("significance_level must be in (0, 1)")
|
|
58
|
+
if self.min_edges < 0:
|
|
59
|
+
raise ValueError("min_edges must be >= 0")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Build the influencer-user interaction table.
|
|
2
|
+
|
|
3
|
+
Edge convention (fixed, stated here and in the README):
|
|
4
|
+
|
|
5
|
+
``a -> b`` means "a retweets/endorses b".
|
|
6
|
+
|
|
7
|
+
Given a list of influencer ids, this module produces the long-form table of
|
|
8
|
+
interactions that the ideology scorer consumes: one row per edge *into* an
|
|
9
|
+
influencer, with columns ``['influencer', 'user']`` (``user`` is the retweeter,
|
|
10
|
+
``influencer`` is the retweeted account).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_influencer_edges(
|
|
20
|
+
graph: nx.DiGraph, influencers: list
|
|
21
|
+
) -> pd.DataFrame:
|
|
22
|
+
"""Return a DataFrame of ``(influencer, user)`` rows for edges into influencers.
|
|
23
|
+
|
|
24
|
+
One row per edge ``user -> influencer`` where ``influencer`` is in
|
|
25
|
+
``influencers``. Self-loops are excluded. No deduplication and no
|
|
26
|
+
special-casing of influencer-influencer edges -- influencer selection and
|
|
27
|
+
edge construction are fully decoupled.
|
|
28
|
+
|
|
29
|
+
Implemented as a single list comprehension feeding one ``pd.DataFrame``
|
|
30
|
+
call; do not regress this to the ``pd.concat``-in-a-loop pattern.
|
|
31
|
+
"""
|
|
32
|
+
influencer_set = set(influencers)
|
|
33
|
+
rows = [
|
|
34
|
+
{"influencer": u, "user": v}
|
|
35
|
+
for u in influencers
|
|
36
|
+
for v in graph.predecessors(u)
|
|
37
|
+
if v != u
|
|
38
|
+
]
|
|
39
|
+
return pd.DataFrame(rows, columns=["influencer", "user"])
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Built-in latent-ideology scorer.
|
|
2
|
+
|
|
3
|
+
Implements correspondence analysis (CA) to map retweet patterns onto a
|
|
4
|
+
bipolar ideology axis, following the method of Falkenberg et al. (2021) and
|
|
5
|
+
Flamino et al. (2021). This is a self-contained, deterministic rewrite of the
|
|
6
|
+
approach, maintained here rather than depending on the external
|
|
7
|
+
``latent-ideology`` package.
|
|
8
|
+
|
|
9
|
+
Method
|
|
10
|
+
------
|
|
11
|
+
1. From the ``(influencer, user)`` interaction table, build a weighted
|
|
12
|
+
``user x influencer`` adjacency matrix ``A`` (weight = number of retweets).
|
|
13
|
+
2. Keep only users who retweeted at least ``min_sources`` distinct
|
|
14
|
+
influencers (the ``n`` threshold of the original method) and, optionally,
|
|
15
|
+
only the top ``max_sources`` influencers by total interactions.
|
|
16
|
+
3. Correspondence analysis: standardize ``A`` to residuals
|
|
17
|
+
``S = Dr^-1/2 (P - r c^T) Dc^-1/2`` and take its truncated SVD.
|
|
18
|
+
4. Row scores ``X = Dr^-1/2 U`` are rescaled to ``[-1, 1]`` per dimension.
|
|
19
|
+
|
|
20
|
+
Deviations from the reference implementation, for reproducibility: SVD is
|
|
21
|
+
computed with ``numpy.linalg.svd`` (deterministic LAPACK) instead of
|
|
22
|
+
``sklearn``'s ``randomized_svd`` (``random_state=None``, non-deterministic).
|
|
23
|
+
|
|
24
|
+
References
|
|
25
|
+
----------
|
|
26
|
+
- M. Falkenberg et al., "Growing polarisation around climate change on social
|
|
27
|
+
media", arXiv:2112.12137 (2021).
|
|
28
|
+
- J. Flamino et al., "Shifting polarization and Twitter news influencers
|
|
29
|
+
between two US presidential elections", arXiv:2111.02505 (2021).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from netpol.scoring import IdeologyScorer
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LatentIdeologyScorer(IdeologyScorer):
|
|
41
|
+
"""Correspondence-analysis ideology scorer.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
min_sources: Minimum number of *distinct* influencers a user must have
|
|
45
|
+
retweeted to be scored. Users below this threshold are dropped.
|
|
46
|
+
This is the ``n`` parameter of the original method (default 2).
|
|
47
|
+
max_sources: If set, restrict to the top ``max_sources`` influencers by
|
|
48
|
+
total interactions (the original ``m`` parameter). ``None`` keeps
|
|
49
|
+
all influencers present in the interaction table.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, min_sources: int = 2, max_sources: int | None = None):
|
|
53
|
+
if min_sources < 1:
|
|
54
|
+
raise ValueError("min_sources must be >= 1")
|
|
55
|
+
if max_sources is not None and max_sources < 2:
|
|
56
|
+
raise ValueError("max_sources must be >= 2 or None")
|
|
57
|
+
self.min_sources = min_sources
|
|
58
|
+
self.max_sources = max_sources
|
|
59
|
+
|
|
60
|
+
def score(self, edges: pd.DataFrame, n_dimensions: int) -> pd.DataFrame:
|
|
61
|
+
if edges is None or edges.empty:
|
|
62
|
+
raise ValueError("cannot score an empty interaction table")
|
|
63
|
+
if "influencer" not in edges.columns or "user" not in edges.columns:
|
|
64
|
+
raise ValueError("edges must have 'influencer' and 'user' columns")
|
|
65
|
+
if n_dimensions < 1:
|
|
66
|
+
raise ValueError("n_dimensions must be >= 1")
|
|
67
|
+
|
|
68
|
+
adjacency, influencers = self._build_adjacency(edges)
|
|
69
|
+
n_users, n_influencers = adjacency.shape
|
|
70
|
+
|
|
71
|
+
if n_users < 2:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"need at least 2 users after filtering, got {n_users}"
|
|
74
|
+
)
|
|
75
|
+
if n_influencers < 2:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"need at least 2 influencers, got {n_influencers}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
scores = self._correspondence_scores(adjacency, n_dimensions)
|
|
81
|
+
return pd.DataFrame(
|
|
82
|
+
scores,
|
|
83
|
+
index=adjacency.index,
|
|
84
|
+
columns=[f"score_{d + 1}" for d in range(scores.shape[1])],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# -- helpers ---------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _build_adjacency(self, edges: pd.DataFrame) -> tuple[pd.DataFrame, list]:
|
|
90
|
+
"""Return the (users x influencers) weighted adjacency matrix.
|
|
91
|
+
|
|
92
|
+
Users touching fewer than ``min_sources`` distinct influencers are
|
|
93
|
+
dropped; empty influencer columns are removed afterwards.
|
|
94
|
+
"""
|
|
95
|
+
df = edges[["user", "influencer"]].copy()
|
|
96
|
+
|
|
97
|
+
# count distinct influencers per user, drop users below threshold
|
|
98
|
+
distinct = df.groupby("user")["influencer"].nunique()
|
|
99
|
+
keep_users = distinct[distinct >= self.min_sources].index
|
|
100
|
+
df = df[df["user"].isin(keep_users)]
|
|
101
|
+
|
|
102
|
+
if self.max_sources is not None:
|
|
103
|
+
top = (
|
|
104
|
+
df.groupby("influencer")
|
|
105
|
+
.size()
|
|
106
|
+
.sort_values(ascending=False)
|
|
107
|
+
.head(self.max_sources)
|
|
108
|
+
.index
|
|
109
|
+
)
|
|
110
|
+
df = df[df["influencer"].isin(top)]
|
|
111
|
+
|
|
112
|
+
# weighted user x influencer matrix (count of interactions)
|
|
113
|
+
weighted = (
|
|
114
|
+
df.groupby(["user", "influencer"]).size().reset_index(name="weight")
|
|
115
|
+
)
|
|
116
|
+
matrix = weighted.pivot(
|
|
117
|
+
index="user", columns="influencer", values="weight"
|
|
118
|
+
).fillna(0.0)
|
|
119
|
+
|
|
120
|
+
# drop influencer columns left with no interactions after filtering
|
|
121
|
+
matrix = matrix.loc[:, (matrix != 0).any(axis=0)]
|
|
122
|
+
return matrix, list(matrix.columns)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _correspondence_scores(
|
|
126
|
+
adjacency: pd.DataFrame, n_dimensions: int
|
|
127
|
+
) -> np.ndarray:
|
|
128
|
+
A = adjacency.to_numpy(dtype=float)
|
|
129
|
+
total = A.sum()
|
|
130
|
+
if total <= 0:
|
|
131
|
+
raise ValueError("adjacency matrix has no interactions")
|
|
132
|
+
|
|
133
|
+
P = A / total
|
|
134
|
+
r = P.sum(axis=1) # row (user) masses
|
|
135
|
+
c = P.sum(axis=0) # column (influencer) masses
|
|
136
|
+
|
|
137
|
+
Dr_inv_sqrt = np.diag(np.power(r, -0.5))
|
|
138
|
+
Dc_inv_sqrt = np.diag(np.power(c, -0.5))
|
|
139
|
+
|
|
140
|
+
S = Dr_inv_sqrt @ (P - np.outer(r, c)) @ Dc_inv_sqrt
|
|
141
|
+
|
|
142
|
+
k = min(n_dimensions, min(S.shape) - 1)
|
|
143
|
+
if k < 1:
|
|
144
|
+
raise ValueError("matrix too small for any ideology dimension")
|
|
145
|
+
|
|
146
|
+
U, _, _ = np.linalg.svd(S, full_matrices=False)
|
|
147
|
+
X = Dr_inv_sqrt @ U[:, :k]
|
|
148
|
+
|
|
149
|
+
# scale each dimension into [-1, 1]
|
|
150
|
+
scaled = np.empty_like(X)
|
|
151
|
+
for d in range(k):
|
|
152
|
+
span = X[:, d].max() - X[:, d].min()
|
|
153
|
+
if span == 0:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"degenerate score distribution in dimension {d + 1}"
|
|
156
|
+
)
|
|
157
|
+
scaled[:, d] = -1 + 2 * (X[:, d] - X[:, d].min()) / span
|
|
158
|
+
return scaled
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Influencer selection.
|
|
2
|
+
|
|
3
|
+
Rank nodes by a centrality score, take the top ``n`` as "influencers" and
|
|
4
|
+
return the rest as "users". This is intentionally one small function rather
|
|
5
|
+
than a plugin system -- see the engineering spec for when to grow it.
|
|
6
|
+
|
|
7
|
+
Edge convention reminder: ``a -> b`` means "a retweets/endorses b", so
|
|
8
|
+
``in_degree`` counts how often a node is retweeted (authority), while total
|
|
9
|
+
``degree`` also counts how often it retweets others.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import networkx as nx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def select_influencers(
|
|
18
|
+
graph: nx.DiGraph, strategy: str, n: int
|
|
19
|
+
) -> tuple[list, list]:
|
|
20
|
+
"""Split the nodes of ``graph`` into influencers and users.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
graph: A directed graph.
|
|
24
|
+
strategy: ``"degree"`` (total degree) or ``"in_degree"``.
|
|
25
|
+
n: Number of influencers to select.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
``(influencers, others)`` where ``influencers`` holds the top ``n``
|
|
29
|
+
nodes by the chosen score (descending) and ``others`` the rest. Ties
|
|
30
|
+
are broken by the stringified node id, ascending, for determinism.
|
|
31
|
+
If ``n`` is greater than or equal to the node count, ``others`` is
|
|
32
|
+
empty (this does not raise).
|
|
33
|
+
"""
|
|
34
|
+
if strategy == "degree":
|
|
35
|
+
scores = graph.degree()
|
|
36
|
+
elif strategy == "in_degree":
|
|
37
|
+
scores = graph.in_degree()
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(f"Unknown strategy: {strategy!r}")
|
|
40
|
+
|
|
41
|
+
ranked = sorted(scores, key=lambda item: (-item[1], str(item[0])))
|
|
42
|
+
|
|
43
|
+
influencers = [node for node, _ in ranked[:n]]
|
|
44
|
+
others = [node for node, _ in ranked[n:]]
|
|
45
|
+
return influencers, others
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Result of analyzing a single layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Hashable
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LayerResult:
|
|
13
|
+
"""Outcome of running the polarization pipeline on one layer.
|
|
14
|
+
|
|
15
|
+
``was_analyzed`` is True iff scoring completed and a dip test was run.
|
|
16
|
+
When a layer is skipped, ``skip_reason`` explains why (instead of the
|
|
17
|
+
original script's silent ``except: continue``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
layer_id: Hashable
|
|
21
|
+
n_nodes: int
|
|
22
|
+
n_edges: int
|
|
23
|
+
influencers: list
|
|
24
|
+
scores: pd.DataFrame | None = None
|
|
25
|
+
dip_statistic: float | None = None
|
|
26
|
+
p_value: float | None = None
|
|
27
|
+
adjusted_p_value: float | None = None
|
|
28
|
+
is_polarized: bool | None = None
|
|
29
|
+
skip_reason: str | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def was_analyzed(self) -> bool:
|
|
33
|
+
return self.skip_reason is None and self.p_value is not None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The polarization pipeline.
|
|
2
|
+
|
|
3
|
+
Two layers, mirroring the API contract in the design spec:
|
|
4
|
+
|
|
5
|
+
* ``analyze_layer`` -- the primitive: one directed graph in, one
|
|
6
|
+
``LayerResult`` out. Fully testable in isolation.
|
|
7
|
+
* ``analyze_layers`` -- thin orchestration over ``dict[layer_id, DiGraph]``
|
|
8
|
+
that runs the primitive per layer and applies the one genuinely cross-layer
|
|
9
|
+
step: Benjamini-Hochberg FDR correction across all layers' p-values.
|
|
10
|
+
|
|
11
|
+
Design constraints enforced here (see the engineering spec):
|
|
12
|
+
|
|
13
|
+
* Directed graphs only -- ``TypeError`` on ``Graph``/``MultiDiGraph``.
|
|
14
|
+
* No bare ``except:`` anywhere; every failure path sets a human-readable
|
|
15
|
+
``skip_reason``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Hashable
|
|
21
|
+
|
|
22
|
+
import networkx as nx
|
|
23
|
+
|
|
24
|
+
from netpol.bimodality import apply_fdr_correction, dip_test
|
|
25
|
+
from netpol.config import PolarizationConfig
|
|
26
|
+
from netpol.edges import build_influencer_edges
|
|
27
|
+
from netpol.ideology import LatentIdeologyScorer
|
|
28
|
+
from netpol.influencers import select_influencers
|
|
29
|
+
from netpol.layer_result import LayerResult
|
|
30
|
+
from netpol.scoring import IdeologyScorer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def analyze_layer(
|
|
34
|
+
graph: nx.DiGraph,
|
|
35
|
+
config: PolarizationConfig,
|
|
36
|
+
ideology_scorer: IdeologyScorer | None = None,
|
|
37
|
+
) -> LayerResult:
|
|
38
|
+
"""Run the polarization pipeline on a single directed graph.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
graph: A directed graph (``a -> b`` = "a retweets b").
|
|
42
|
+
config: Run configuration.
|
|
43
|
+
ideology_scorer: Optional scorer. Defaults to
|
|
44
|
+
:class:`netpol.ideology.LatentIdeologyScorer`.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A ``LayerResult``. ``is_polarized`` reflects the raw p-value only;
|
|
48
|
+
FDR correction is applied later by ``analyze_layers``.
|
|
49
|
+
"""
|
|
50
|
+
_require_digraph(graph)
|
|
51
|
+
|
|
52
|
+
result = LayerResult(
|
|
53
|
+
layer_id=None,
|
|
54
|
+
n_nodes=graph.number_of_nodes(),
|
|
55
|
+
n_edges=graph.number_of_edges(),
|
|
56
|
+
influencers=[],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if graph.number_of_edges() < config.min_edges:
|
|
60
|
+
result.skip_reason = (
|
|
61
|
+
f"below min_edges ({graph.number_of_edges()} < {config.min_edges})"
|
|
62
|
+
)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
influencers, _ = select_influencers(
|
|
66
|
+
graph, config.influencer_strategy, config.n_influencers
|
|
67
|
+
)
|
|
68
|
+
result.influencers = influencers
|
|
69
|
+
|
|
70
|
+
edges = build_influencer_edges(graph, influencers)
|
|
71
|
+
if edges.empty:
|
|
72
|
+
result.skip_reason = "no edges into selected influencers"
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
scorer = ideology_scorer or LatentIdeologyScorer()
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
scores = scorer.score(edges, config.ideology_dimensions)
|
|
79
|
+
except Exception as exc: # scorer is a plug point; report, don't crash
|
|
80
|
+
result.skip_reason = f"scoring_failed: {type(exc).__name__}: {exc}"
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
result.scores = scores
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
dip, p_value = dip_test(scores["score_1"].to_numpy())
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
result.skip_reason = f"dip_test_failed: {type(exc).__name__}: {exc}"
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
result.dip_statistic = dip
|
|
92
|
+
result.p_value = p_value
|
|
93
|
+
result.is_polarized = p_value < config.significance_level
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def analyze_layers(
|
|
98
|
+
layers: dict[Hashable, nx.DiGraph],
|
|
99
|
+
config: PolarizationConfig,
|
|
100
|
+
ideology_scorer: IdeologyScorer | None = None,
|
|
101
|
+
) -> dict[Hashable, LayerResult]:
|
|
102
|
+
"""Run ``analyze_layer`` per layer and apply FDR correction across layers.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
layers: Mapping ``layer_id -> DiGraph``.
|
|
106
|
+
config: Run configuration. ``exclude_layers`` entries are skipped
|
|
107
|
+
with a ``skip_reason``.
|
|
108
|
+
ideology_scorer: Optional scorer; defaults to the built-in one.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Mapping ``layer_id -> LayerResult``. When ``config.fdr_correction``
|
|
112
|
+
is True, ``adjusted_p_value`` is set on analyzed layers and
|
|
113
|
+
``is_polarized`` is re-evaluated against the adjusted p-value.
|
|
114
|
+
"""
|
|
115
|
+
results: dict[Hashable, LayerResult] = {}
|
|
116
|
+
|
|
117
|
+
for layer_id, graph in layers.items():
|
|
118
|
+
result = analyze_layer(graph, config, ideology_scorer)
|
|
119
|
+
result.layer_id = layer_id
|
|
120
|
+
if layer_id in config.exclude_layers:
|
|
121
|
+
result.skip_reason = "excluded by config.exclude_layers"
|
|
122
|
+
result.dip_statistic = None
|
|
123
|
+
result.p_value = None
|
|
124
|
+
result.scores = None
|
|
125
|
+
result.is_polarized = None
|
|
126
|
+
results[layer_id] = result
|
|
127
|
+
|
|
128
|
+
if config.fdr_correction:
|
|
129
|
+
raw = {
|
|
130
|
+
layer_id: r.p_value
|
|
131
|
+
for layer_id, r in results.items()
|
|
132
|
+
if r.p_value is not None
|
|
133
|
+
}
|
|
134
|
+
adjusted = apply_fdr_correction(raw)
|
|
135
|
+
for layer_id, r in results.items():
|
|
136
|
+
if r.p_value is None:
|
|
137
|
+
continue
|
|
138
|
+
r.adjusted_p_value = adjusted[layer_id]
|
|
139
|
+
r.is_polarized = adjusted[layer_id] < config.significance_level
|
|
140
|
+
|
|
141
|
+
return results
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _require_digraph(graph: nx.DiGraph) -> None:
|
|
145
|
+
if isinstance(graph, nx.MultiDiGraph):
|
|
146
|
+
raise TypeError("MultiDiGraph is not supported")
|
|
147
|
+
if not isinstance(graph, nx.DiGraph):
|
|
148
|
+
raise TypeError(
|
|
149
|
+
"expected a networkx.DiGraph; undirected and multigraphs are not "
|
|
150
|
+
"supported (the edge direction encodes who retweets whom)"
|
|
151
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The ideology-scoring plug point.
|
|
2
|
+
|
|
3
|
+
The pipeline only depends on the ``IdeologyScorer`` protocol below, never on a
|
|
4
|
+
specific implementation. ``netpol`` ships a default implementation
|
|
5
|
+
(``netpol.ideology.LatentIdeologyScorer``) that users can ignore, but the
|
|
6
|
+
protocol lets them swap in their own correspondence-analysis code, a different
|
|
7
|
+
method entirely, or a patched/expanded version of the built-in scorer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IdeologyScorer(Protocol):
|
|
18
|
+
"""Something that maps an influencer-user interaction table to scores.
|
|
19
|
+
|
|
20
|
+
Implementations receive the output of
|
|
21
|
+
:func:`netpol.edges.build_influencer_edges` and must return one row per
|
|
22
|
+
scored node, indexed by node id, with one column per dimension named
|
|
23
|
+
``score_1`` ... ``score_n``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def score(self, edges: pd.DataFrame, n_dimensions: int) -> pd.DataFrame:
|
|
27
|
+
"""Compute per-node ideology scores.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
edges: DataFrame with columns ``['influencer', 'user']``.
|
|
31
|
+
n_dimensions: Number of score dimensions to return.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A DataFrame indexed by node id with columns
|
|
35
|
+
``['score_1', ..., 'score_n']``.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: if ``edges`` is empty or scoring cannot proceed.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "netpol"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Measure polarization in (multilayer) social networks via latent-ideology scoring and Hartigan's dip test."
|
|
5
|
+
authors = ["alessiogandelli <alessiogandelli99@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/alessiogandelli/netpol"
|
|
9
|
+
repository = "https://github.com/alessiogandelli/netpol"
|
|
10
|
+
keywords = [
|
|
11
|
+
"polarization",
|
|
12
|
+
"social-networks",
|
|
13
|
+
"networkx",
|
|
14
|
+
"multilayer",
|
|
15
|
+
"ideology",
|
|
16
|
+
"dip-test",
|
|
17
|
+
"correspondence-analysis",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
29
|
+
]
|
|
30
|
+
packages = [{ include = "netpol" }]
|
|
31
|
+
|
|
32
|
+
[tool.poetry.dependencies]
|
|
33
|
+
python = ">=3.10"
|
|
34
|
+
networkx = ">=3.0"
|
|
35
|
+
pandas = ">=2.0"
|
|
36
|
+
numpy = ">=1.24"
|
|
37
|
+
diptest = ">=0.8"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.group.dev.dependencies]
|
|
40
|
+
pytest = ">=7.0"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|