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