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,407 @@
|
|
|
1
|
+
"""Main CorrelationNetwork class for building correlation/association graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import networkx as nx
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from correlation_network._types import AssociationMethod
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import polars as pl
|
|
19
|
+
from correlation_network._validation import check_is_fitted
|
|
20
|
+
from correlation_network.association._auto import AutoAssociationStrategy
|
|
21
|
+
from correlation_network.association._base import AssociationStrategy
|
|
22
|
+
from correlation_network.association._categorical import (
|
|
23
|
+
CorrelationRatioStrategy,
|
|
24
|
+
CramersVStrategy,
|
|
25
|
+
)
|
|
26
|
+
from correlation_network.association._correlation import (
|
|
27
|
+
KendallStrategy,
|
|
28
|
+
PearsonStrategy,
|
|
29
|
+
SpearmanStrategy,
|
|
30
|
+
)
|
|
31
|
+
from correlation_network.association._universal import (
|
|
32
|
+
MutualInformationStrategy,
|
|
33
|
+
PhiKStrategy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_STRATEGY_REGISTRY: dict[str, type[AssociationStrategy]] = {
|
|
37
|
+
"pearson": PearsonStrategy,
|
|
38
|
+
"spearman": SpearmanStrategy,
|
|
39
|
+
"kendall": KendallStrategy,
|
|
40
|
+
"cramers_v": CramersVStrategy,
|
|
41
|
+
"correlation_ratio": CorrelationRatioStrategy,
|
|
42
|
+
"phik": PhiKStrategy,
|
|
43
|
+
"mutual_information": MutualInformationStrategy,
|
|
44
|
+
"auto": AutoAssociationStrategy,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
AVAILABLE_METHODS = tuple(_STRATEGY_REGISTRY.keys())
|
|
48
|
+
|
|
49
|
+
# Sentinel `method` for instances built by `from_matrix`: they carry no
|
|
50
|
+
# strategy, so this is deliberately not one of AVAILABLE_METHODS.
|
|
51
|
+
PRECOMPUTED_METHOD = "precomputed"
|
|
52
|
+
|
|
53
|
+
_DEFAULT_CARDINALITY_THRESHOLD = 0.95
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn_user(message: str) -> None:
|
|
60
|
+
"""Emit a UserWarning attributed to the first caller outside this package.
|
|
61
|
+
|
|
62
|
+
A fixed ``stacklevel`` breaks as soon as the warning is reached through
|
|
63
|
+
a different call path (``fit`` vs ``fit_transform``), pointing users at
|
|
64
|
+
library internals instead of their own code.
|
|
65
|
+
"""
|
|
66
|
+
frame: FrameType | None = sys._getframe(1)
|
|
67
|
+
stacklevel = 2
|
|
68
|
+
while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE_DIR):
|
|
69
|
+
frame = frame.f_back
|
|
70
|
+
stacklevel += 1
|
|
71
|
+
warnings.warn(message, UserWarning, stacklevel=stacklevel)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_consecutive_sequence(series: pd.Series) -> bool:
|
|
75
|
+
"""Check whether the distinct values form a run of consecutive integers."""
|
|
76
|
+
values = np.sort(series.dropna().unique())
|
|
77
|
+
return len(values) > 1 and bool((np.diff(values) == 1).all())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _to_pandas(dataframe: Any) -> pd.DataFrame:
|
|
81
|
+
"""Convert a Polars DataFrame to pandas if needed.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
dataframe : pd.DataFrame | pl.DataFrame | pl.LazyFrame
|
|
86
|
+
Input data.
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
pd.DataFrame
|
|
91
|
+
Pandas DataFrame ready for processing.
|
|
92
|
+
|
|
93
|
+
Raises
|
|
94
|
+
------
|
|
95
|
+
TypeError
|
|
96
|
+
If the input is not a supported DataFrame type.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(dataframe, pd.DataFrame):
|
|
99
|
+
return dataframe
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
import polars as pl
|
|
103
|
+
except ImportError:
|
|
104
|
+
raise TypeError(
|
|
105
|
+
f"Expected a pandas DataFrame, got {type(dataframe).__name__}. "
|
|
106
|
+
"Install polars for Polars support: "
|
|
107
|
+
"pip install 'correlation-network[polars]'"
|
|
108
|
+
) from None
|
|
109
|
+
|
|
110
|
+
if isinstance(dataframe, pl.LazyFrame):
|
|
111
|
+
return dataframe.collect().to_pandas()
|
|
112
|
+
if isinstance(dataframe, pl.DataFrame):
|
|
113
|
+
return dataframe.to_pandas()
|
|
114
|
+
|
|
115
|
+
raise TypeError(f"Expected a pandas or Polars DataFrame, got {type(dataframe).__name__}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CorrelationNetwork:
|
|
119
|
+
"""Build a network graph from pairwise association measures.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
method : AssociationMethod
|
|
124
|
+
Association method to use. One of: ``"pearson"``, ``"spearman"``,
|
|
125
|
+
``"kendall"``, ``"cramers_v"``, ``"correlation_ratio"``,
|
|
126
|
+
``"phik"``, ``"mutual_information"``, ``"auto"``.
|
|
127
|
+
threshold : float
|
|
128
|
+
Minimum absolute association value to create an edge; the comparison
|
|
129
|
+
is inclusive, so ``|value| == threshold`` keeps the edge.
|
|
130
|
+
Must be in the range (0, 1).
|
|
131
|
+
strategy : AssociationStrategy | None
|
|
132
|
+
Custom strategy instance. Overrides ``method`` if provided.
|
|
133
|
+
cardinality_threshold : float | None
|
|
134
|
+
Maximum allowed cardinality ratio (nunique / nrows) for a column.
|
|
135
|
+
String and categorical columns reaching this ratio are excluded as
|
|
136
|
+
index-like variables; integer columns are excluded only if their
|
|
137
|
+
values also form a consecutive sequence (row numbers, auto-increment
|
|
138
|
+
keys). Set to ``None`` to disable the filter. Default ``0.95``.
|
|
139
|
+
|
|
140
|
+
Attributes
|
|
141
|
+
----------
|
|
142
|
+
association_matrix_ : pd.DataFrame
|
|
143
|
+
Raw association matrix (available after ``fit``).
|
|
144
|
+
adjacency_matrix_ : pd.DataFrame
|
|
145
|
+
Thresholded association matrix (available after ``fit``).
|
|
146
|
+
excluded_columns_ : list[str]
|
|
147
|
+
Columns excluded during preprocessing (datetime or high-cardinality)
|
|
148
|
+
or left out by the strategy because of an unsupported type.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
method: AssociationMethod = "pearson",
|
|
154
|
+
threshold: float = 0.5,
|
|
155
|
+
strategy: AssociationStrategy | None = None,
|
|
156
|
+
cardinality_threshold: float | None = _DEFAULT_CARDINALITY_THRESHOLD,
|
|
157
|
+
) -> None:
|
|
158
|
+
if method not in AVAILABLE_METHODS:
|
|
159
|
+
raise ValueError(f"Unknown method {method!r}. Available options: {AVAILABLE_METHODS}")
|
|
160
|
+
if not (0 < threshold < 1):
|
|
161
|
+
raise ValueError(f"The threshold must be in the range (0, 1), got {threshold}.")
|
|
162
|
+
# Widened beyond the constructor types: from_matrix sets the
|
|
163
|
+
# "precomputed" sentinel method and may leave threshold as None.
|
|
164
|
+
self.method: str = method
|
|
165
|
+
self.threshold: float | None = threshold
|
|
166
|
+
self.cardinality_threshold = cardinality_threshold
|
|
167
|
+
self.strategy = strategy
|
|
168
|
+
|
|
169
|
+
def fit(
|
|
170
|
+
self,
|
|
171
|
+
dataframe: pd.DataFrame | pl.DataFrame | pl.LazyFrame,
|
|
172
|
+
columns: list[str] | None = None,
|
|
173
|
+
) -> CorrelationNetwork:
|
|
174
|
+
"""Compute the association matrix and apply thresholding.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
dataframe : pd.DataFrame | pl.DataFrame | pl.LazyFrame
|
|
179
|
+
Input data. Polars DataFrames are converted to pandas
|
|
180
|
+
internally (requires the ``polars`` extra).
|
|
181
|
+
columns : list[str] | None
|
|
182
|
+
Subset of columns to use. Uses all columns if None.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
CorrelationNetwork
|
|
187
|
+
The fitted instance (for method chaining).
|
|
188
|
+
"""
|
|
189
|
+
dataframe = _to_pandas(dataframe)
|
|
190
|
+
if columns is not None:
|
|
191
|
+
dataframe = dataframe.loc[:, columns]
|
|
192
|
+
|
|
193
|
+
dataframe = self._preprocess(dataframe)
|
|
194
|
+
|
|
195
|
+
strategy = (
|
|
196
|
+
self.strategy if self.strategy is not None else _STRATEGY_REGISTRY[self.method]()
|
|
197
|
+
)
|
|
198
|
+
self.association_matrix_ = strategy.compute(dataframe)
|
|
199
|
+
self._record_unused_columns(input_columns=dataframe.columns)
|
|
200
|
+
self.adjacency_matrix_ = self._apply_threshold(self.association_matrix_)
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_matrix(
|
|
205
|
+
cls,
|
|
206
|
+
matrix: pd.DataFrame | pl.DataFrame,
|
|
207
|
+
*,
|
|
208
|
+
threshold: float | None = None,
|
|
209
|
+
) -> CorrelationNetwork:
|
|
210
|
+
"""Create an instance from a pre-computed association matrix.
|
|
211
|
+
|
|
212
|
+
The returned instance is immediately ready for ``transform()``
|
|
213
|
+
without calling ``fit()``.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
matrix : pd.DataFrame | pl.DataFrame
|
|
218
|
+
Pre-computed symmetric association matrix.
|
|
219
|
+
Index and columns must match. Polars DataFrames are
|
|
220
|
+
converted to pandas internally.
|
|
221
|
+
threshold : float | None
|
|
222
|
+
If provided, values below this absolute threshold are zeroed
|
|
223
|
+
in the adjacency matrix. If ``None``, the matrix is used as-is.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
CorrelationNetwork
|
|
228
|
+
Fitted instance ready for ``transform()``.
|
|
229
|
+
|
|
230
|
+
Raises
|
|
231
|
+
------
|
|
232
|
+
TypeError
|
|
233
|
+
If ``matrix`` is not a supported DataFrame type.
|
|
234
|
+
ValueError
|
|
235
|
+
If the matrix is not square, labels don't match,
|
|
236
|
+
or it is not symmetric.
|
|
237
|
+
"""
|
|
238
|
+
is_pandas = isinstance(matrix, pd.DataFrame)
|
|
239
|
+
matrix = _to_pandas(matrix)
|
|
240
|
+
if matrix.shape[0] != matrix.shape[1]:
|
|
241
|
+
raise ValueError(f"Matrix must be square, got shape {matrix.shape}")
|
|
242
|
+
if not is_pandas:
|
|
243
|
+
# Polars frames carry no row index, so rows can only be labelled
|
|
244
|
+
# by position; the converted RangeIndex would never match.
|
|
245
|
+
matrix.index = matrix.columns
|
|
246
|
+
if not matrix.index.equals(matrix.columns):
|
|
247
|
+
raise ValueError("Row index must match column index")
|
|
248
|
+
if not np.allclose(matrix.values, matrix.values.T, atol=1e-8):
|
|
249
|
+
raise ValueError("Matrix must be symmetric")
|
|
250
|
+
|
|
251
|
+
if threshold is not None and not (0 < threshold < 1):
|
|
252
|
+
raise ValueError(f"The threshold must be in the range (0, 1), got {threshold}.")
|
|
253
|
+
|
|
254
|
+
instance = cls.__new__(cls)
|
|
255
|
+
instance.method = PRECOMPUTED_METHOD
|
|
256
|
+
instance.threshold = threshold
|
|
257
|
+
instance.cardinality_threshold = None
|
|
258
|
+
instance.strategy = None
|
|
259
|
+
instance.excluded_columns_ = []
|
|
260
|
+
instance.association_matrix_ = matrix
|
|
261
|
+
# `_apply_threshold` is a no-op when `threshold` is None.
|
|
262
|
+
instance.adjacency_matrix_ = instance._apply_threshold(matrix)
|
|
263
|
+
|
|
264
|
+
return instance
|
|
265
|
+
|
|
266
|
+
def transform(self) -> nx.Graph:
|
|
267
|
+
"""Build a NetworkX graph from the fitted adjacency matrix.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
nx.Graph
|
|
272
|
+
Weighted undirected graph with self-loops removed.
|
|
273
|
+
|
|
274
|
+
Raises
|
|
275
|
+
------
|
|
276
|
+
NotFittedError
|
|
277
|
+
If ``fit`` has not been called.
|
|
278
|
+
"""
|
|
279
|
+
check_is_fitted(self)
|
|
280
|
+
graph = nx.from_pandas_adjacency(df=self.adjacency_matrix_)
|
|
281
|
+
graph.remove_edges_from(nx.selfloop_edges(graph))
|
|
282
|
+
return graph
|
|
283
|
+
|
|
284
|
+
def fit_transform(
|
|
285
|
+
self,
|
|
286
|
+
dataframe: pd.DataFrame | pl.DataFrame | pl.LazyFrame,
|
|
287
|
+
columns: list[str] | None = None,
|
|
288
|
+
) -> nx.Graph:
|
|
289
|
+
"""Fit and transform in a single step.
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
dataframe : pd.DataFrame | pl.DataFrame | pl.LazyFrame
|
|
294
|
+
Input data. Polars DataFrames are converted to pandas
|
|
295
|
+
internally (requires the ``polars`` extra).
|
|
296
|
+
columns : list[str] | None
|
|
297
|
+
Subset of columns to use.
|
|
298
|
+
|
|
299
|
+
Returns
|
|
300
|
+
-------
|
|
301
|
+
nx.Graph
|
|
302
|
+
Weighted undirected graph.
|
|
303
|
+
"""
|
|
304
|
+
self.fit(dataframe, columns=columns)
|
|
305
|
+
return self.transform()
|
|
306
|
+
|
|
307
|
+
def _preprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
308
|
+
"""Exclude datetime and high-cardinality columns.
|
|
309
|
+
|
|
310
|
+
Parameters
|
|
311
|
+
----------
|
|
312
|
+
dataframe : pd.DataFrame
|
|
313
|
+
Input data (already subsetted by ``columns`` if provided).
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
pd.DataFrame
|
|
318
|
+
Cleaned DataFrame with problematic columns removed.
|
|
319
|
+
|
|
320
|
+
Raises
|
|
321
|
+
------
|
|
322
|
+
ValueError
|
|
323
|
+
If the DataFrame is empty or fewer than 2 columns remain.
|
|
324
|
+
"""
|
|
325
|
+
if len(dataframe) == 0:
|
|
326
|
+
raise ValueError("Cannot fit on an empty DataFrame.")
|
|
327
|
+
|
|
328
|
+
excluded: list[str] = []
|
|
329
|
+
|
|
330
|
+
# Exclude datetime columns
|
|
331
|
+
datetime_cols = dataframe.select_dtypes(
|
|
332
|
+
include=["datetime64", "datetimetz"]
|
|
333
|
+
).columns.tolist()
|
|
334
|
+
if datetime_cols:
|
|
335
|
+
_warn_user(f"Datetime columns excluded: {datetime_cols}")
|
|
336
|
+
excluded.extend(datetime_cols)
|
|
337
|
+
dataframe = dataframe.drop(columns=datetime_cols)
|
|
338
|
+
|
|
339
|
+
if self.cardinality_threshold is not None:
|
|
340
|
+
high_cardinality_cols = self._high_cardinality_columns(
|
|
341
|
+
dataframe=dataframe, threshold=self.cardinality_threshold
|
|
342
|
+
)
|
|
343
|
+
if high_cardinality_cols:
|
|
344
|
+
_warn_user(f"High-cardinality columns excluded: {high_cardinality_cols}")
|
|
345
|
+
excluded.extend(high_cardinality_cols)
|
|
346
|
+
dataframe = dataframe.drop(columns=high_cardinality_cols)
|
|
347
|
+
|
|
348
|
+
self.excluded_columns_ = excluded
|
|
349
|
+
|
|
350
|
+
if len(dataframe.columns) < 2:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"Fewer than 2 columns remain after preprocessing. Excluded columns: {excluded}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return dataframe
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _high_cardinality_columns(dataframe: pd.DataFrame, threshold: float) -> list[str]:
|
|
359
|
+
"""Return index-like columns whose cardinality ratio is too high.
|
|
360
|
+
|
|
361
|
+
Continuous floats are never checked: they are naturally near-unique and
|
|
362
|
+
still produce valid correlations. Integer columns must additionally be
|
|
363
|
+
a consecutive sequence once sorted (row numbers, auto-increment keys);
|
|
364
|
+
on small frames genuine integer measurements are near-unique as well.
|
|
365
|
+
"""
|
|
366
|
+
candidates = dataframe.select_dtypes(include=["integer", "object", "category"])
|
|
367
|
+
ratio = candidates.nunique() / len(dataframe)
|
|
368
|
+
flagged = ratio[ratio >= threshold].index
|
|
369
|
+
return [
|
|
370
|
+
col
|
|
371
|
+
for col in flagged
|
|
372
|
+
if not pd.api.types.is_integer_dtype(dataframe[col])
|
|
373
|
+
or _is_consecutive_sequence(dataframe[col])
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
def _record_unused_columns(self, input_columns: pd.Index) -> None:
|
|
377
|
+
"""Warn about and record columns the strategy left out of its matrix.
|
|
378
|
+
|
|
379
|
+
Strategies only use the column types they support (e.g. correlations
|
|
380
|
+
skip strings); without this, such columns would vanish from the graph
|
|
381
|
+
without notice.
|
|
382
|
+
"""
|
|
383
|
+
computed = set(self.association_matrix_.columns)
|
|
384
|
+
unused = [col for col in input_columns if col not in computed]
|
|
385
|
+
if unused:
|
|
386
|
+
_warn_user(f"Columns not used by the strategy (unsupported type): {unused}")
|
|
387
|
+
self.excluded_columns_.extend(unused)
|
|
388
|
+
|
|
389
|
+
def _apply_threshold(self, matrix: pd.DataFrame) -> pd.DataFrame:
|
|
390
|
+
"""Zero out values below the threshold (by absolute value).
|
|
391
|
+
|
|
392
|
+
A ``None`` threshold means "keep everything", which is the state an
|
|
393
|
+
instance built by :meth:`from_matrix` without a threshold is left in.
|
|
394
|
+
The comparison is inclusive, matching the documented contract that
|
|
395
|
+
``threshold`` is the *minimum* value that still creates an edge.
|
|
396
|
+
"""
|
|
397
|
+
if self.threshold is None:
|
|
398
|
+
return matrix
|
|
399
|
+
condition = (matrix >= self.threshold) | (matrix <= -self.threshold)
|
|
400
|
+
return matrix.where(cond=condition, other=0)
|
|
401
|
+
|
|
402
|
+
def __repr__(self) -> str:
|
|
403
|
+
if self.strategy is not None:
|
|
404
|
+
source = f"strategy={type(self.strategy).__name__}"
|
|
405
|
+
else:
|
|
406
|
+
source = f"method={self.method!r}"
|
|
407
|
+
return f"CorrelationNetwork({source}, threshold={self.threshold})"
|
|
File without changes
|