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.
@@ -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
+ )