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,56 @@
|
|
|
1
|
+
"""correlation-network: Build and analyze association networks from DataFrames."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from correlation_network._types import (
|
|
6
|
+
AssociationMethod,
|
|
7
|
+
BinningMethod,
|
|
8
|
+
CentralityMetric,
|
|
9
|
+
LayoutAlgorithm,
|
|
10
|
+
)
|
|
11
|
+
from correlation_network.association import (
|
|
12
|
+
AssociationStrategy,
|
|
13
|
+
AutoAssociationStrategy,
|
|
14
|
+
CorrelationRatioStrategy,
|
|
15
|
+
CramersVStrategy,
|
|
16
|
+
KendallStrategy,
|
|
17
|
+
MutualInformationStrategy,
|
|
18
|
+
PearsonStrategy,
|
|
19
|
+
PhiKStrategy,
|
|
20
|
+
SpearmanStrategy,
|
|
21
|
+
)
|
|
22
|
+
from correlation_network.centrality import CentralityAnalyzer
|
|
23
|
+
from correlation_network.exceptions import (
|
|
24
|
+
CorrelationNetworkError,
|
|
25
|
+
InsufficientColumnsError,
|
|
26
|
+
NoCompleteObservationsError,
|
|
27
|
+
NotFittedError,
|
|
28
|
+
)
|
|
29
|
+
from correlation_network.network import CorrelationNetwork
|
|
30
|
+
from correlation_network.visualization import NetworkVisualizer
|
|
31
|
+
|
|
32
|
+
# Read from the installed metadata so pyproject.toml stays the single source
|
|
33
|
+
__version__ = version("correlation-network")
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AssociationMethod",
|
|
37
|
+
"AssociationStrategy",
|
|
38
|
+
"AutoAssociationStrategy",
|
|
39
|
+
"BinningMethod",
|
|
40
|
+
"CentralityAnalyzer",
|
|
41
|
+
"CentralityMetric",
|
|
42
|
+
"CorrelationNetwork",
|
|
43
|
+
"CorrelationNetworkError",
|
|
44
|
+
"CorrelationRatioStrategy",
|
|
45
|
+
"CramersVStrategy",
|
|
46
|
+
"InsufficientColumnsError",
|
|
47
|
+
"KendallStrategy",
|
|
48
|
+
"LayoutAlgorithm",
|
|
49
|
+
"MutualInformationStrategy",
|
|
50
|
+
"NetworkVisualizer",
|
|
51
|
+
"NoCompleteObservationsError",
|
|
52
|
+
"NotFittedError",
|
|
53
|
+
"PearsonStrategy",
|
|
54
|
+
"PhiKStrategy",
|
|
55
|
+
"SpearmanStrategy",
|
|
56
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared graph utility functions."""
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
# Floor for converted distances. A |weight| of 1.0 would otherwise map to a
|
|
6
|
+
# zero-length edge, collapsing two nodes into the same point for path-based
|
|
7
|
+
# metrics and making closeness degenerate.
|
|
8
|
+
_MIN_DISTANCE = 1e-6
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def distance_graph(graph: nx.Graph) -> nx.Graph:
|
|
12
|
+
"""Return a copy with edge weights converted to distances.
|
|
13
|
+
|
|
14
|
+
Association strength and graph distance run in opposite directions: a
|
|
15
|
+
strong association (``|w|`` near 1) means two nodes are *close*, while
|
|
16
|
+
NetworkX shortest-path algorithms read the edge attribute as a *cost*.
|
|
17
|
+
Passing raw association values to Dijkstra therefore routes paths through
|
|
18
|
+
the weakest edges. This helper applies ``distance = 1 - |weight|`` so that
|
|
19
|
+
strong associations become short distances.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
graph : nx.Graph
|
|
24
|
+
A weighted undirected graph whose weights are association values.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
nx.Graph
|
|
29
|
+
Copy with edge weights replaced by distances, floored at a small
|
|
30
|
+
positive value so no edge has zero or negative length.
|
|
31
|
+
"""
|
|
32
|
+
g = graph.copy()
|
|
33
|
+
nx.set_edge_attributes(
|
|
34
|
+
g,
|
|
35
|
+
values={
|
|
36
|
+
(u, v): max(_MIN_DISTANCE, 1.0 - abs(d.get("weight", 1.0)))
|
|
37
|
+
for u, v, d in g.edges(data=True)
|
|
38
|
+
},
|
|
39
|
+
name="weight",
|
|
40
|
+
)
|
|
41
|
+
return g
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def abs_weight_graph(graph: nx.Graph) -> nx.Graph:
|
|
45
|
+
"""Return a copy of the graph with absolute edge weights.
|
|
46
|
+
|
|
47
|
+
Useful for algorithms (Dijkstra, layout) that require non-negative weights.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
graph : nx.Graph
|
|
52
|
+
A weighted undirected graph.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
nx.Graph
|
|
57
|
+
Copy with all edge weights replaced by their absolute values.
|
|
58
|
+
"""
|
|
59
|
+
g = graph.copy()
|
|
60
|
+
nx.set_edge_attributes(
|
|
61
|
+
g,
|
|
62
|
+
values={(u, v): abs(d.get("weight", 1)) for u, v, d in g.edges(data=True)},
|
|
63
|
+
name="weight",
|
|
64
|
+
)
|
|
65
|
+
return g
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Type aliases and literal types."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
CorrelationMethod = Literal["pearson", "spearman", "kendall"]
|
|
6
|
+
|
|
7
|
+
AssociationMethod = Literal[
|
|
8
|
+
"pearson",
|
|
9
|
+
"spearman",
|
|
10
|
+
"kendall",
|
|
11
|
+
"cramers_v",
|
|
12
|
+
"correlation_ratio",
|
|
13
|
+
"phik",
|
|
14
|
+
"mutual_information",
|
|
15
|
+
"auto",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
CentralityMetric = Literal[
|
|
19
|
+
"degree",
|
|
20
|
+
"strength",
|
|
21
|
+
"betweenness",
|
|
22
|
+
"eigenvector",
|
|
23
|
+
"closeness",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
LayoutAlgorithm = Literal[
|
|
27
|
+
"kamada_kawai",
|
|
28
|
+
"spring",
|
|
29
|
+
"circular",
|
|
30
|
+
"shell",
|
|
31
|
+
"spectral",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
BinningMethod = Literal["uniform", "quantile"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Input validation utilities."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from correlation_network.exceptions import NoCompleteObservationsError, NotFittedError
|
|
9
|
+
|
|
10
|
+
NOT_FITTED_MESSAGE = (
|
|
11
|
+
"The instance is not fitted yet. "
|
|
12
|
+
"Call 'fit' with appropriate arguments before using the estimator."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_is_fitted(
|
|
17
|
+
estimator: object,
|
|
18
|
+
attributes: set[str] | None = None,
|
|
19
|
+
message: str = NOT_FITTED_MESSAGE,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Check whether an estimator has been fitted.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
estimator : object
|
|
26
|
+
The estimator instance to check.
|
|
27
|
+
attributes : set[str] | None
|
|
28
|
+
Fitted attributes to look for. Defaults to ``{"adjacency_matrix_"}``.
|
|
29
|
+
message : str
|
|
30
|
+
Error message raised when the estimator is not fitted.
|
|
31
|
+
|
|
32
|
+
Raises
|
|
33
|
+
------
|
|
34
|
+
NotFittedError
|
|
35
|
+
If none of the expected fitted attributes are found.
|
|
36
|
+
"""
|
|
37
|
+
if attributes is None:
|
|
38
|
+
attributes = {"adjacency_matrix_"}
|
|
39
|
+
object_attrs = set(vars(estimator))
|
|
40
|
+
if not attributes.intersection(object_attrs):
|
|
41
|
+
raise NotFittedError(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def complete_pair(x: pd.Series, y: pd.Series) -> tuple[pd.Series, pd.Series]:
|
|
45
|
+
"""Restrict two series to the rows where both values are present.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
x : pd.Series
|
|
50
|
+
First column of the pair.
|
|
51
|
+
y : pd.Series
|
|
52
|
+
Second column of the pair, aligned with ``x``.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
tuple[pd.Series, pd.Series]
|
|
57
|
+
Both series, filtered to their pairwise-complete rows.
|
|
58
|
+
|
|
59
|
+
Raises
|
|
60
|
+
------
|
|
61
|
+
NoCompleteObservationsError
|
|
62
|
+
If the two series share no rows where both values are present.
|
|
63
|
+
"""
|
|
64
|
+
valid = x.notna() & y.notna()
|
|
65
|
+
if not valid.any():
|
|
66
|
+
raise NoCompleteObservationsError(columns=(str(x.name), str(y.name)))
|
|
67
|
+
return x[valid], y[valid]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_pairwise_overlap(dataframe: pd.DataFrame) -> None:
|
|
71
|
+
"""Ensure every column pair shares at least one complete row.
|
|
72
|
+
|
|
73
|
+
Matrix-level counterpart of :func:`complete_pair`, for strategies that
|
|
74
|
+
delegate the pairwise computation to pandas, which reports such pairs as
|
|
75
|
+
``NaN`` rather than raising.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
dataframe : pd.DataFrame
|
|
80
|
+
Columns to check.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
NoCompleteObservationsError
|
|
85
|
+
For the first column pair with no rows where both values are present.
|
|
86
|
+
"""
|
|
87
|
+
present = dataframe.notna().to_numpy()
|
|
88
|
+
if len(present) and present.all():
|
|
89
|
+
return
|
|
90
|
+
# Float matmul goes through BLAS; integer matmul does not and takes
|
|
91
|
+
# seconds on large frames. Counts stay exact far beyond realistic sizes.
|
|
92
|
+
present = present.astype(np.float64)
|
|
93
|
+
overlap = present.T @ present
|
|
94
|
+
rows, cols = np.nonzero(np.triu(overlap == 0, k=1))
|
|
95
|
+
if rows.size:
|
|
96
|
+
names = dataframe.columns
|
|
97
|
+
raise NoCompleteObservationsError(columns=(str(names[rows[0]]), str(names[cols[0]])))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_option(name: str, value: object, options: Collection[str]) -> None:
|
|
101
|
+
"""Ensure a string argument is one of the supported options.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
name : str
|
|
106
|
+
Argument name, used in the error message.
|
|
107
|
+
value : object
|
|
108
|
+
Value received.
|
|
109
|
+
options : Collection[str]
|
|
110
|
+
Accepted values.
|
|
111
|
+
|
|
112
|
+
Raises
|
|
113
|
+
------
|
|
114
|
+
ValueError
|
|
115
|
+
If ``value`` is not in ``options``.
|
|
116
|
+
"""
|
|
117
|
+
if value not in options:
|
|
118
|
+
raise ValueError(f"Unknown {name} {value!r}. Available options: {tuple(options)}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Association strategies for computing pairwise relationships."""
|
|
2
|
+
|
|
3
|
+
from correlation_network.association._auto import AutoAssociationStrategy
|
|
4
|
+
from correlation_network.association._base import AssociationStrategy
|
|
5
|
+
from correlation_network.association._categorical import (
|
|
6
|
+
CorrelationRatioStrategy,
|
|
7
|
+
CramersVStrategy,
|
|
8
|
+
)
|
|
9
|
+
from correlation_network.association._correlation import (
|
|
10
|
+
KendallStrategy,
|
|
11
|
+
PearsonStrategy,
|
|
12
|
+
SpearmanStrategy,
|
|
13
|
+
)
|
|
14
|
+
from correlation_network.association._universal import (
|
|
15
|
+
MutualInformationStrategy,
|
|
16
|
+
PhiKStrategy,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AssociationStrategy",
|
|
21
|
+
"AutoAssociationStrategy",
|
|
22
|
+
"CorrelationRatioStrategy",
|
|
23
|
+
"CramersVStrategy",
|
|
24
|
+
"KendallStrategy",
|
|
25
|
+
"MutualInformationStrategy",
|
|
26
|
+
"PearsonStrategy",
|
|
27
|
+
"PhiKStrategy",
|
|
28
|
+
"SpearmanStrategy",
|
|
29
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Auto-detection strategy that dispatches by column type."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from correlation_network.association._base import AssociationStrategy
|
|
7
|
+
from correlation_network.association._categorical import (
|
|
8
|
+
CorrelationRatioStrategy,
|
|
9
|
+
CramersVStrategy,
|
|
10
|
+
)
|
|
11
|
+
from correlation_network.association._correlation import SpearmanStrategy
|
|
12
|
+
from correlation_network.association._dtypes import (
|
|
13
|
+
categorical_columns,
|
|
14
|
+
numeric_columns,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoAssociationStrategy:
|
|
19
|
+
"""Automatically selects the appropriate association measure per column pair.
|
|
20
|
+
|
|
21
|
+
Default dispatch:
|
|
22
|
+
- numeric-numeric: Spearman correlation
|
|
23
|
+
- categorical-categorical: Cramer's V
|
|
24
|
+
- categorical-numeric: Correlation ratio (eta)
|
|
25
|
+
|
|
26
|
+
Bool and string columns count as categorical.
|
|
27
|
+
|
|
28
|
+
Notes
|
|
29
|
+
-----
|
|
30
|
+
Numeric-numeric correlations are reduced to their **absolute value**, so
|
|
31
|
+
the resulting matrix is in [0, 1] and the sign of a negative correlation
|
|
32
|
+
is lost. This keeps the three measures on a comparable scale (Cramer's V
|
|
33
|
+
and eta are already non-negative), but means ``method="auto"`` never
|
|
34
|
+
produces negative edge weights — the positive/negative edge colouring in
|
|
35
|
+
:class:`~correlation_network.NetworkVisualizer` has no effect on such a
|
|
36
|
+
graph. Use an explicit correlation method to retain the sign.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
numeric_strategy : AssociationStrategy | None
|
|
41
|
+
Strategy for numeric-numeric pairs. Defaults to SpearmanStrategy.
|
|
42
|
+
categorical_strategy : AssociationStrategy | None
|
|
43
|
+
Strategy for categorical-categorical pairs. Defaults to CramersVStrategy.
|
|
44
|
+
mixed_strategy : AssociationStrategy | None
|
|
45
|
+
Strategy for categorical-numeric pairs. Defaults to CorrelationRatioStrategy.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
numeric_strategy: AssociationStrategy | None = None,
|
|
51
|
+
categorical_strategy: AssociationStrategy | None = None,
|
|
52
|
+
mixed_strategy: AssociationStrategy | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._numeric = numeric_strategy if numeric_strategy is not None else SpearmanStrategy()
|
|
55
|
+
self._categorical = (
|
|
56
|
+
categorical_strategy if categorical_strategy is not None else CramersVStrategy()
|
|
57
|
+
)
|
|
58
|
+
self._mixed = mixed_strategy if mixed_strategy is not None else CorrelationRatioStrategy()
|
|
59
|
+
|
|
60
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
61
|
+
"""Compute a combined association matrix using type-aware dispatch.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
dataframe : pd.DataFrame
|
|
66
|
+
Input data with numeric and/or categorical columns.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
pd.DataFrame
|
|
71
|
+
Symmetric association matrix combining all column types.
|
|
72
|
+
"""
|
|
73
|
+
num_cols = numeric_columns(dataframe)
|
|
74
|
+
cat_cols = categorical_columns(dataframe)
|
|
75
|
+
all_cols = num_cols + cat_cols
|
|
76
|
+
n = len(all_cols)
|
|
77
|
+
matrix = np.eye(n)
|
|
78
|
+
col_index = {col: i for i, col in enumerate(all_cols)}
|
|
79
|
+
|
|
80
|
+
# Numeric-numeric block: assign via numpy fancy indexing
|
|
81
|
+
if len(num_cols) > 1:
|
|
82
|
+
num_matrix = self._numeric.compute(dataframe[num_cols])
|
|
83
|
+
num_idx = np.array([col_index[c] for c in num_cols])
|
|
84
|
+
matrix[np.ix_(num_idx, num_idx)] = num_matrix.abs().values
|
|
85
|
+
|
|
86
|
+
# Categorical-categorical block: assign via numpy fancy indexing
|
|
87
|
+
if len(cat_cols) > 1:
|
|
88
|
+
cat_matrix = self._categorical.compute(dataframe[cat_cols])
|
|
89
|
+
cat_idx = np.array([col_index[c] for c in cat_cols])
|
|
90
|
+
matrix[np.ix_(cat_idx, cat_idx)] = cat_matrix.values
|
|
91
|
+
|
|
92
|
+
# Mixed block (cat-num): assign via numpy fancy indexing
|
|
93
|
+
if num_cols and cat_cols:
|
|
94
|
+
mixed_matrix = self._mixed.compute(dataframe[num_cols + cat_cols])
|
|
95
|
+
cat_idx = np.array([col_index[c] for c in cat_cols])
|
|
96
|
+
num_idx = np.array([col_index[c] for c in num_cols])
|
|
97
|
+
sub = mixed_matrix.loc[cat_cols, num_cols].values
|
|
98
|
+
matrix[np.ix_(cat_idx, num_idx)] = sub
|
|
99
|
+
matrix[np.ix_(num_idx, cat_idx)] = sub.T
|
|
100
|
+
|
|
101
|
+
# Restore diagonal after block assignments
|
|
102
|
+
np.fill_diagonal(matrix, 1.0)
|
|
103
|
+
|
|
104
|
+
return pd.DataFrame(data=matrix, index=all_cols, columns=all_cols)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Base protocol for association strategies."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class AssociationStrategy(Protocol):
|
|
10
|
+
"""Protocol that all association strategies must implement."""
|
|
11
|
+
|
|
12
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
13
|
+
"""Compute a symmetric association matrix from a DataFrame.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
dataframe : pd.DataFrame
|
|
18
|
+
Input data with columns to compute associations for.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
pd.DataFrame
|
|
23
|
+
Symmetric matrix of association values.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Association strategies for categorical and mixed-type pairs."""
|
|
2
|
+
|
|
3
|
+
from itertools import combinations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from scipy import stats
|
|
8
|
+
|
|
9
|
+
from correlation_network._validation import complete_pair
|
|
10
|
+
from correlation_network.association._dtypes import (
|
|
11
|
+
categorical_columns,
|
|
12
|
+
numeric_columns,
|
|
13
|
+
)
|
|
14
|
+
from correlation_network.exceptions import InsufficientColumnsError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CramersVStrategy:
|
|
18
|
+
"""Cramer's V association measure for categorical-categorical pairs.
|
|
19
|
+
|
|
20
|
+
Computes the bias-corrected Cramer's V for all pairs of categorical
|
|
21
|
+
columns, returning values in [0, 1].
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
25
|
+
"""Compute Cramer's V matrix for categorical columns.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
dataframe : pd.DataFrame
|
|
30
|
+
Input data. Only object, category, bool and string columns are
|
|
31
|
+
used.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
pd.DataFrame
|
|
36
|
+
Symmetric matrix of Cramer's V values in [0, 1].
|
|
37
|
+
|
|
38
|
+
Raises
|
|
39
|
+
------
|
|
40
|
+
InsufficientColumnsError
|
|
41
|
+
If fewer than 2 categorical columns are present.
|
|
42
|
+
NoCompleteObservationsError
|
|
43
|
+
If a pair has no rows where both columns are present.
|
|
44
|
+
"""
|
|
45
|
+
categorical_cols = categorical_columns(dataframe)
|
|
46
|
+
if len(categorical_cols) < 2:
|
|
47
|
+
raise InsufficientColumnsError(
|
|
48
|
+
method="cramers_v",
|
|
49
|
+
required="at least 2 categorical columns",
|
|
50
|
+
found=categorical_cols,
|
|
51
|
+
)
|
|
52
|
+
n_cols = len(categorical_cols)
|
|
53
|
+
matrix = np.eye(n_cols)
|
|
54
|
+
|
|
55
|
+
for i, j in combinations(range(n_cols), 2):
|
|
56
|
+
value = self._cramers_v(
|
|
57
|
+
x=dataframe[categorical_cols[i]],
|
|
58
|
+
y=dataframe[categorical_cols[j]],
|
|
59
|
+
)
|
|
60
|
+
matrix[i, j] = value
|
|
61
|
+
matrix[j, i] = value
|
|
62
|
+
|
|
63
|
+
return pd.DataFrame(data=matrix, index=categorical_cols, columns=categorical_cols)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _cramers_v(x: pd.Series, y: pd.Series) -> float:
|
|
67
|
+
"""Compute bias-corrected Cramer's V between two categorical series."""
|
|
68
|
+
x, y = complete_pair(x, y)
|
|
69
|
+
contingency = pd.crosstab(index=x, columns=y)
|
|
70
|
+
# Yates' continuity correction (scipy's default on 2x2 tables) shrinks
|
|
71
|
+
# chi2 and would bias V downward on top of the explicit correction below.
|
|
72
|
+
chi2 = stats.chi2_contingency(observed=contingency, correction=False)[0]
|
|
73
|
+
n = contingency.values.sum()
|
|
74
|
+
r, k = contingency.shape
|
|
75
|
+
|
|
76
|
+
# Bias correction
|
|
77
|
+
phi2 = chi2 / n
|
|
78
|
+
phi2_corrected = max(0.0, phi2 - ((k - 1) * (r - 1)) / (n - 1))
|
|
79
|
+
r_corrected = r - ((r - 1) ** 2) / (n - 1)
|
|
80
|
+
k_corrected = k - ((k - 1) ** 2) / (n - 1)
|
|
81
|
+
denominator = min(k_corrected - 1, r_corrected - 1)
|
|
82
|
+
|
|
83
|
+
if denominator <= 0:
|
|
84
|
+
return 0.0
|
|
85
|
+
return np.sqrt(phi2_corrected / denominator)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CorrelationRatioStrategy:
|
|
89
|
+
"""Correlation ratio (eta) for categorical-numeric pairs.
|
|
90
|
+
|
|
91
|
+
For each pair of (categorical, numeric) columns, computes the
|
|
92
|
+
correlation ratio in [0, 1].
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
96
|
+
"""Compute correlation ratio matrix for mixed-type columns.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
dataframe : pd.DataFrame
|
|
101
|
+
Input data with both categorical and numeric columns.
|
|
102
|
+
|
|
103
|
+
Each pair is evaluated on the rows where both columns are present,
|
|
104
|
+
matching the pairwise-complete convention the other strategies use.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
pd.DataFrame
|
|
109
|
+
Symmetric matrix of correlation ratio values in [0, 1].
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
InsufficientColumnsError
|
|
114
|
+
If either no categorical or no numeric column is present.
|
|
115
|
+
NoCompleteObservationsError
|
|
116
|
+
If a pair has no rows where both columns are present.
|
|
117
|
+
"""
|
|
118
|
+
cat_cols = categorical_columns(dataframe)
|
|
119
|
+
num_cols = numeric_columns(dataframe)
|
|
120
|
+
if not cat_cols or not num_cols:
|
|
121
|
+
raise InsufficientColumnsError(
|
|
122
|
+
method="correlation_ratio",
|
|
123
|
+
required="at least 1 categorical and 1 numeric column",
|
|
124
|
+
found=cat_cols + num_cols,
|
|
125
|
+
)
|
|
126
|
+
all_cols = cat_cols + num_cols
|
|
127
|
+
n = len(all_cols)
|
|
128
|
+
matrix = np.zeros((n, n))
|
|
129
|
+
col_index = {col: i for i, col in enumerate(all_cols)}
|
|
130
|
+
|
|
131
|
+
# Per-column stats cannot be pre-computed: each pair is restricted to
|
|
132
|
+
# its own complete rows, so the grand mean and total sum of squares
|
|
133
|
+
# depend on which categorical column it is paired with.
|
|
134
|
+
for cat_col in cat_cols:
|
|
135
|
+
for num_col in num_cols:
|
|
136
|
+
value = self._correlation_ratio(
|
|
137
|
+
categories=dataframe[cat_col],
|
|
138
|
+
values=dataframe[num_col],
|
|
139
|
+
)
|
|
140
|
+
ci = col_index[cat_col]
|
|
141
|
+
ni = col_index[num_col]
|
|
142
|
+
matrix[ci, ni] = value
|
|
143
|
+
matrix[ni, ci] = value
|
|
144
|
+
|
|
145
|
+
np.fill_diagonal(matrix, 1.0)
|
|
146
|
+
|
|
147
|
+
return pd.DataFrame(data=matrix, index=all_cols, columns=all_cols)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _correlation_ratio(categories: pd.Series, values: pd.Series) -> float:
|
|
151
|
+
"""Compute correlation ratio (eta) for a categorical-numeric pair.
|
|
152
|
+
|
|
153
|
+
Restricted to rows where both columns are present. Computing the total
|
|
154
|
+
sum of squares over every row while the between-group sum only covers
|
|
155
|
+
rows with a known category would divide two quantities measured on
|
|
156
|
+
different populations, understating the association.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
NoCompleteObservationsError
|
|
161
|
+
If the two columns share no rows where both values are present.
|
|
162
|
+
"""
|
|
163
|
+
categories, values = complete_pair(categories, values)
|
|
164
|
+
|
|
165
|
+
grand_mean = values.mean()
|
|
166
|
+
ss_total = ((values - grand_mean) ** 2).sum()
|
|
167
|
+
# A constant column has no variance for the categories to explain.
|
|
168
|
+
# That is uninformative rather than undefined, so report no association.
|
|
169
|
+
if ss_total == 0:
|
|
170
|
+
return 0.0
|
|
171
|
+
|
|
172
|
+
group_stats = values.groupby(categories, observed=True).agg(["mean", "count"])
|
|
173
|
+
ss_between = (group_stats["count"] * (group_stats["mean"] - grand_mean) ** 2).sum()
|
|
174
|
+
return np.sqrt(ss_between / ss_total)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Correlation-based association strategies for numeric-numeric pairs."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from correlation_network._types import CorrelationMethod
|
|
6
|
+
from correlation_network._validation import check_pairwise_overlap
|
|
7
|
+
from correlation_network.association._dtypes import numeric_columns
|
|
8
|
+
from correlation_network.exceptions import InsufficientColumnsError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _CorrelationStrategy:
|
|
12
|
+
"""Base class for pandas correlation methods."""
|
|
13
|
+
|
|
14
|
+
_method: CorrelationMethod
|
|
15
|
+
|
|
16
|
+
def compute(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
17
|
+
"""Compute correlation matrix using pandas.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
dataframe : pd.DataFrame
|
|
22
|
+
Input data with numeric columns.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
pd.DataFrame
|
|
27
|
+
Symmetric correlation matrix with values in [-1, 1].
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
InsufficientColumnsError
|
|
32
|
+
If fewer than 2 numeric columns are present.
|
|
33
|
+
NoCompleteObservationsError
|
|
34
|
+
If a pair has no rows where both columns are present.
|
|
35
|
+
"""
|
|
36
|
+
numeric_cols = numeric_columns(dataframe)
|
|
37
|
+
if len(numeric_cols) < 2:
|
|
38
|
+
raise InsufficientColumnsError(
|
|
39
|
+
method=self._method,
|
|
40
|
+
required="at least 2 numeric columns",
|
|
41
|
+
found=numeric_cols,
|
|
42
|
+
)
|
|
43
|
+
matrix = dataframe.corr(method=self._method, numeric_only=True)
|
|
44
|
+
# numeric_only also keeps bool columns, so check the columns pandas
|
|
45
|
+
# actually used rather than numeric_cols
|
|
46
|
+
check_pairwise_overlap(dataframe[matrix.columns])
|
|
47
|
+
return matrix
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PearsonStrategy(_CorrelationStrategy):
|
|
51
|
+
"""Pearson linear correlation coefficient."""
|
|
52
|
+
|
|
53
|
+
_method: CorrelationMethod = "pearson"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SpearmanStrategy(_CorrelationStrategy):
|
|
57
|
+
"""Spearman rank correlation coefficient."""
|
|
58
|
+
|
|
59
|
+
_method: CorrelationMethod = "spearman"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class KendallStrategy(_CorrelationStrategy):
|
|
63
|
+
"""Kendall tau rank correlation coefficient."""
|
|
64
|
+
|
|
65
|
+
_method: CorrelationMethod = "kendall"
|