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 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"