correlation-network 1.0.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.
- correlation_network/__init__.py +56 -0
- correlation_network/_graph_utils.py +65 -0
- correlation_network/_types.py +34 -0
- correlation_network/_validation.py +118 -0
- correlation_network/association/__init__.py +29 -0
- correlation_network/association/_auto.py +104 -0
- correlation_network/association/_base.py +25 -0
- correlation_network/association/_categorical.py +174 -0
- correlation_network/association/_correlation.py +65 -0
- correlation_network/association/_dtypes.py +47 -0
- correlation_network/association/_universal.py +162 -0
- correlation_network/centrality.py +124 -0
- correlation_network/exceptions.py +61 -0
- correlation_network/network.py +407 -0
- correlation_network/py.typed +0 -0
- correlation_network/visualization.py +394 -0
- correlation_network-1.0.0.dist-info/METADATA +385 -0
- correlation_network-1.0.0.dist-info/RECORD +20 -0
- correlation_network-1.0.0.dist-info/WHEEL +4 -0
- correlation_network-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Column type detection shared by the type-aware association strategies."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
# bool and pandas' "string" dtype hold labels, not quantities. select_dtypes
|
|
8
|
+
# leaves both out of "number", so unless they are listed here every
|
|
9
|
+
# type-aware strategy drops them without notice.
|
|
10
|
+
CATEGORICAL_DTYPES: tuple[Literal["object", "category", "bool", "string"], ...] = (
|
|
11
|
+
"object",
|
|
12
|
+
"category",
|
|
13
|
+
"bool",
|
|
14
|
+
"string",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def categorical_columns(dataframe: pd.DataFrame) -> list[str]:
|
|
19
|
+
"""Return the columns treated as categorical.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
dataframe : pd.DataFrame
|
|
24
|
+
Input data.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
list[str]
|
|
29
|
+
Object, category, bool and string columns, in frame order.
|
|
30
|
+
"""
|
|
31
|
+
return dataframe.select_dtypes(include=list(CATEGORICAL_DTYPES)).columns.tolist()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def numeric_columns(dataframe: pd.DataFrame) -> list[str]:
|
|
35
|
+
"""Return the columns treated as numeric (bool excluded).
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
dataframe : pd.DataFrame
|
|
40
|
+
Input data.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
list[str]
|
|
45
|
+
Numeric columns, in frame order.
|
|
46
|
+
"""
|
|
47
|
+
return dataframe.select_dtypes(include="number").columns.tolist()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Universal association strategies that work with any column type."""
|
|
2
|
+
|
|
3
|
+
from itertools import combinations
|
|
4
|
+
from typing import get_args
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from scipy.stats import entropy
|
|
9
|
+
|
|
10
|
+
from correlation_network._types import BinningMethod
|
|
11
|
+
from correlation_network._validation import (
|
|
12
|
+
check_option,
|
|
13
|
+
check_pairwise_overlap,
|
|
14
|
+
complete_pair,
|
|
15
|
+
)
|
|
16
|
+
from correlation_network.association._dtypes import numeric_columns
|
|
17
|
+
from correlation_network.exceptions import InsufficientColumnsError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PhiKStrategy:
|
|
21
|
+
"""PhiK correlation coefficient (requires optional phik dependency).
|
|
22
|
+
|
|
23
|
+
Works with any combination of column types. Returns values in [0, 1].
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
27
|
+
"""Compute PhiK correlation matrix.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
dataframe : pd.DataFrame
|
|
32
|
+
Input data with any column types.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
pd.DataFrame
|
|
37
|
+
Symmetric matrix of PhiK values in [0, 1].
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ImportError
|
|
42
|
+
If the ``phik`` package is not installed.
|
|
43
|
+
NoCompleteObservationsError
|
|
44
|
+
If a pair has no rows where both columns are present.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
import phik # noqa: F401
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"PhiK strategy requires the 'phik' package. "
|
|
51
|
+
"Install it with: pip install 'correlation-network[phik]'"
|
|
52
|
+
) from None
|
|
53
|
+
|
|
54
|
+
# phik reports pairs without overlapping rows as NaN instead of raising
|
|
55
|
+
check_pairwise_overlap(dataframe)
|
|
56
|
+
# phik registers this DataFrame method at import time, which the
|
|
57
|
+
# pandas stubs cannot know about
|
|
58
|
+
return dataframe.phik_matrix() # type: ignore[operator]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MutualInformationStrategy:
|
|
62
|
+
"""Normalized mutual information for any column type.
|
|
63
|
+
|
|
64
|
+
Discretizes numeric columns before computing MI. Returns values in [0, 1].
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
n_bins : int
|
|
69
|
+
Number of bins for numeric columns. Default ``10``.
|
|
70
|
+
binning : BinningMethod
|
|
71
|
+
``"uniform"`` (default) splits the value range into equal-width bins;
|
|
72
|
+
``"quantile"`` uses equal-frequency bins, which a few extreme values
|
|
73
|
+
cannot squeeze into a single bin. Duplicate quantile edges are merged,
|
|
74
|
+
so heavily repeated values may yield fewer than ``n_bins`` bins.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
If ``binning`` is not a supported method.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, n_bins: int = 10, binning: BinningMethod = "uniform") -> None:
|
|
83
|
+
check_option(name="binning", value=binning, options=get_args(BinningMethod))
|
|
84
|
+
self.n_bins = n_bins
|
|
85
|
+
self.binning = binning
|
|
86
|
+
|
|
87
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
88
|
+
"""Compute normalized mutual information matrix.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
dataframe : pd.DataFrame
|
|
93
|
+
Input data with any column types.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
pd.DataFrame
|
|
98
|
+
Symmetric matrix of normalized MI values in [0, 1].
|
|
99
|
+
|
|
100
|
+
Raises
|
|
101
|
+
------
|
|
102
|
+
InsufficientColumnsError
|
|
103
|
+
If fewer than 2 columns are present.
|
|
104
|
+
NoCompleteObservationsError
|
|
105
|
+
If a pair has no rows where both columns are present.
|
|
106
|
+
"""
|
|
107
|
+
if len(dataframe.columns) < 2:
|
|
108
|
+
raise InsufficientColumnsError(
|
|
109
|
+
method="mutual_information",
|
|
110
|
+
required="at least 2 columns",
|
|
111
|
+
found=dataframe.columns.tolist(),
|
|
112
|
+
)
|
|
113
|
+
discretized = self._discretize(dataframe)
|
|
114
|
+
columns = discretized.columns.tolist()
|
|
115
|
+
n = len(columns)
|
|
116
|
+
matrix = np.eye(n)
|
|
117
|
+
|
|
118
|
+
for i, j in combinations(range(n), 2):
|
|
119
|
+
value = self._normalized_mi(
|
|
120
|
+
x=discretized[columns[i]],
|
|
121
|
+
y=discretized[columns[j]],
|
|
122
|
+
)
|
|
123
|
+
matrix[i, j] = value
|
|
124
|
+
matrix[j, i] = value
|
|
125
|
+
|
|
126
|
+
return pd.DataFrame(data=matrix, index=columns, columns=columns)
|
|
127
|
+
|
|
128
|
+
def _discretize(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
129
|
+
"""Discretize numeric columns into bins."""
|
|
130
|
+
result = dataframe.copy()
|
|
131
|
+
numeric_cols = numeric_columns(result)
|
|
132
|
+
result[numeric_cols] = result[numeric_cols].apply(self._bin)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def _bin(self, column: pd.Series) -> pd.Series:
|
|
136
|
+
"""Map one numeric column to integer bin labels."""
|
|
137
|
+
match self.binning:
|
|
138
|
+
case "quantile":
|
|
139
|
+
return pd.qcut(column, q=self.n_bins, labels=False, duplicates="drop")
|
|
140
|
+
case _:
|
|
141
|
+
return pd.cut(column, bins=self.n_bins, labels=False)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _normalized_mi(x: pd.Series, y: pd.Series) -> float:
|
|
145
|
+
"""Compute normalized mutual information between two discrete series."""
|
|
146
|
+
x_clean, y_clean = complete_pair(x, y)
|
|
147
|
+
|
|
148
|
+
contingency = pd.crosstab(index=x_clean, columns=y_clean)
|
|
149
|
+
joint_prob = contingency.values / contingency.values.sum()
|
|
150
|
+
|
|
151
|
+
marginal_x = joint_prob.sum(axis=1)
|
|
152
|
+
marginal_y = joint_prob.sum(axis=0)
|
|
153
|
+
|
|
154
|
+
h_x = entropy(marginal_x)
|
|
155
|
+
h_y = entropy(marginal_y)
|
|
156
|
+
|
|
157
|
+
if h_x == 0 or h_y == 0:
|
|
158
|
+
return 0.0
|
|
159
|
+
|
|
160
|
+
h_xy = entropy(joint_prob.ravel())
|
|
161
|
+
mi = h_x + h_y - h_xy
|
|
162
|
+
return float(mi / np.sqrt(h_x * h_y))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Centrality analysis for association networks."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
|
|
5
|
+
import networkx as nx
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from correlation_network._graph_utils import abs_weight_graph, distance_graph
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CentralityAnalyzer:
|
|
12
|
+
"""Compute centrality metrics for a NetworkX graph.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
graph : nx.Graph
|
|
17
|
+
A weighted undirected graph.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, graph: nx.Graph) -> None:
|
|
21
|
+
self.graph = graph
|
|
22
|
+
|
|
23
|
+
@functools.cached_property
|
|
24
|
+
def _abs_graph(self) -> nx.Graph:
|
|
25
|
+
"""Graph copy with absolute weights, for magnitude-based metrics."""
|
|
26
|
+
return abs_weight_graph(self.graph)
|
|
27
|
+
|
|
28
|
+
@functools.cached_property
|
|
29
|
+
def _distance_graph(self) -> nx.Graph:
|
|
30
|
+
"""Graph copy with weights as distances, for path-based metrics (Dijkstra)."""
|
|
31
|
+
return distance_graph(self.graph)
|
|
32
|
+
|
|
33
|
+
def degree(self) -> pd.Series:
|
|
34
|
+
"""Normalized degree centrality.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
pd.Series
|
|
39
|
+
Fraction of possible connections for each node, sorted descending.
|
|
40
|
+
"""
|
|
41
|
+
return pd.Series(nx.degree_centrality(self.graph), name="degree").sort_values(
|
|
42
|
+
ascending=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def strength(self) -> pd.Series:
|
|
46
|
+
"""Weighted degree (sum of absolute edge weights).
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
pd.Series
|
|
51
|
+
Weighted degree for each node, sorted descending.
|
|
52
|
+
"""
|
|
53
|
+
weighted_degree = dict(self._abs_graph.degree(weight="weight"))
|
|
54
|
+
return pd.Series(weighted_degree, name="strength").sort_values(ascending=False)
|
|
55
|
+
|
|
56
|
+
def betweenness(self) -> pd.Series:
|
|
57
|
+
"""Betweenness centrality.
|
|
58
|
+
|
|
59
|
+
Association values are converted to distances (``1 - |weight|``) so
|
|
60
|
+
that shortest paths run through strongly associated pairs.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
pd.Series
|
|
65
|
+
Betweenness centrality for each node, sorted descending.
|
|
66
|
+
"""
|
|
67
|
+
return pd.Series(
|
|
68
|
+
nx.betweenness_centrality(self._distance_graph, weight="weight"),
|
|
69
|
+
name="betweenness",
|
|
70
|
+
).sort_values(ascending=False)
|
|
71
|
+
|
|
72
|
+
def eigenvector(self) -> pd.Series:
|
|
73
|
+
"""Eigenvector centrality.
|
|
74
|
+
|
|
75
|
+
Computed on absolute weights: with signed weights the adjacency matrix
|
|
76
|
+
loses the Perron-Frobenius guarantee of a non-negative leading
|
|
77
|
+
eigenvector, and nodes can receive negative scores. Falls back to the
|
|
78
|
+
NumPy solver if the power iteration does not converge.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
pd.Series
|
|
83
|
+
Eigenvector centrality for each node, sorted descending.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
values = nx.eigenvector_centrality(self._abs_graph, weight="weight")
|
|
87
|
+
except nx.PowerIterationFailedConvergence:
|
|
88
|
+
values = nx.eigenvector_centrality_numpy(self._abs_graph, weight="weight")
|
|
89
|
+
return pd.Series(values, name="eigenvector").sort_values(ascending=False)
|
|
90
|
+
|
|
91
|
+
def closeness(self) -> pd.Series:
|
|
92
|
+
"""Closeness centrality.
|
|
93
|
+
|
|
94
|
+
Association values are converted to distances (``1 - |weight|``) so
|
|
95
|
+
that strongly associated nodes are treated as close together.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
pd.Series
|
|
100
|
+
Closeness centrality for each node, sorted descending.
|
|
101
|
+
"""
|
|
102
|
+
return pd.Series(
|
|
103
|
+
nx.closeness_centrality(self._distance_graph, distance="weight"),
|
|
104
|
+
name="closeness",
|
|
105
|
+
).sort_values(ascending=False)
|
|
106
|
+
|
|
107
|
+
def summary(self) -> pd.DataFrame:
|
|
108
|
+
"""Compute all centrality metrics and return as a DataFrame.
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
pd.DataFrame
|
|
113
|
+
DataFrame with columns: degree, strength, betweenness,
|
|
114
|
+
eigenvector, closeness. Indexed by node name.
|
|
115
|
+
"""
|
|
116
|
+
return pd.DataFrame(
|
|
117
|
+
{
|
|
118
|
+
"degree": self.degree(),
|
|
119
|
+
"strength": self.strength(),
|
|
120
|
+
"betweenness": self.betweenness(),
|
|
121
|
+
"eigenvector": self.eigenvector(),
|
|
122
|
+
"closeness": self.closeness(),
|
|
123
|
+
}
|
|
124
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Custom exception hierarchy for correlation-network."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CorrelationNetworkError(Exception):
|
|
5
|
+
"""Base class for all errors raised by correlation-network.
|
|
6
|
+
|
|
7
|
+
Every concrete error also inherits from the built-in it historically
|
|
8
|
+
derived from (``ValueError``, ``AttributeError``), so existing handlers
|
|
9
|
+
keep working.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotFittedError(CorrelationNetworkError, ValueError, AttributeError):
|
|
14
|
+
"""Raised when an estimator is used before fitting.
|
|
15
|
+
|
|
16
|
+
Inherits from both ValueError and AttributeError for compatibility
|
|
17
|
+
with scikit-learn-style exception handling.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NoCompleteObservationsError(CorrelationNetworkError, ValueError):
|
|
22
|
+
"""Raised when a column pair shares no rows where both values are present.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
columns : tuple[str, str]
|
|
27
|
+
The pair of column names with no overlapping complete rows.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, columns: tuple[str, str]) -> None:
|
|
31
|
+
self.columns = columns
|
|
32
|
+
left, right = columns
|
|
33
|
+
super().__init__(
|
|
34
|
+
f"Columns {left!r} and {right!r} share no rows where both values "
|
|
35
|
+
f"are present, so their association is undefined. Drop or impute "
|
|
36
|
+
f"the missing values, or exclude one of the columns."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsufficientColumnsError(CorrelationNetworkError, ValueError):
|
|
41
|
+
"""Raised when a strategy receives too few columns of the type it needs.
|
|
42
|
+
|
|
43
|
+
Attributes
|
|
44
|
+
----------
|
|
45
|
+
method : str
|
|
46
|
+
Name of the strategy that rejected the input.
|
|
47
|
+
required : str
|
|
48
|
+
Human-readable description of the columns the strategy needs.
|
|
49
|
+
found : list[str]
|
|
50
|
+
Columns of the required type that were actually present.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, method: str, required: str, found: list[str]) -> None:
|
|
54
|
+
self.method = method
|
|
55
|
+
self.required = required
|
|
56
|
+
self.found = found
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"{method} requires {required}, but the DataFrame provides "
|
|
59
|
+
f"{len(found)}: {found}. Pick a method suited to these column "
|
|
60
|
+
f"types, or select only compatible columns."
|
|
61
|
+
)
|