Skip to content

Commit d29cff8

Browse files
committed
Add type annotations on input and return values
1 parent bc28ab7 commit d29cff8

8 files changed

Lines changed: 343 additions & 150 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
dev = [
2828
"ruff",
2929
"pytest",
30+
"typing-extensions>=4.15.0",
3031
]
3132

3233
[project.urls]

src/probabilit/correlation.py

Lines changed: 86 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,19 @@
3030
0.279652...
3131
"""
3232

33-
import numpy as np
34-
import scipy as sp
3533
import abc
36-
import dataclasses
37-
3834
import contextlib
39-
import os
35+
import dataclasses
4036
import itertools
37+
import os
38+
from collections.abc import Iterable, Iterator
39+
from typing import Any
40+
41+
import numpy as np
42+
import scipy as sp
43+
from typing_extensions import Self
44+
45+
from probabilit.types import Array1D, Array2D, CorrelationType
4146

4247
# CVXPY prints error messages about incompatible ortools version during import.
4348
# Since we use the SCS solver and not GLOP/PDLP (which need ortools), these errors
@@ -56,7 +61,13 @@ class CorrelatorError(Exception):
5661
pass
5762

5863

59-
def nearest_correlation_matrix(matrix, *, weights=None, eps=1e-6, verbose=False):
64+
def nearest_correlation_matrix(
65+
matrix: Array2D,
66+
*,
67+
weights: Array2D | None = None,
68+
eps: float = 1e-6,
69+
verbose: bool = False,
70+
) -> Array2D:
6071
"""Returns the correlation matrix nearest to `matrix`, weighted elementwise
6172
by `weights`.
6273
@@ -156,7 +167,7 @@ def nearest_correlation_matrix(matrix, *, weights=None, eps=1e-6, verbose=False)
156167
return X
157168

158169

159-
def _is_positive_definite(X):
170+
def _is_positive_definite(X: Array2D) -> bool:
160171
try:
161172
np.linalg.cholesky(X)
162173
return True
@@ -165,7 +176,7 @@ def _is_positive_definite(X):
165176

166177

167178
class Correlator(abc.ABC):
168-
def set_target(self, correlation_matrix, cholesky=True):
179+
def set_target(self, correlation_matrix: Array2D, cholesky: bool = True) -> Self:
169180
"""Set target correlation matrix."""
170181
if not isinstance(correlation_matrix, np.ndarray):
171182
raise TypeError("Input argument `correlation_matrix` must be NumPy array.")
@@ -185,7 +196,7 @@ def set_target(self, correlation_matrix, cholesky=True):
185196
self.P = np.linalg.cholesky(self.C)
186197
return self
187198

188-
def _validate_X(self, X, check_rows_cols=True):
199+
def _validate_X(self, X: Array2D, check_rows_cols: bool = True) -> tuple[int, int]:
189200
"""Validate array X of shape (observations, variables)."""
190201
if not (hasattr(self, "C")):
191202
raise CorrelatorError("User must call `set_target` first.")
@@ -246,11 +257,11 @@ class Cholesky(Correlator):
246257
247258
"""
248259

249-
def set_target(self, correlation_matrix):
260+
def set_target(self, correlation_matrix: Array2D) -> Self:
250261
super().set_target(correlation_matrix)
251262
return self
252263

253-
def __call__(self, X):
264+
def __call__(self, X: Array2D) -> Array2D:
254265
"""Transform an input matrix X.
255266
256267
Parameters
@@ -370,11 +381,11 @@ class ImanConover(Correlator):
370381
np.float64(0.592541)
371382
"""
372383

373-
def set_target(self, correlation_matrix):
384+
def set_target(self, correlation_matrix: Array2D) -> Self:
374385
super().set_target(correlation_matrix)
375386
return self
376387

377-
def __call__(self, X):
388+
def __call__(self, X: Array2D) -> Array2D:
378389
"""Transform an input matrix X.
379390
380391
The output will have the same marginal distributions, but with
@@ -455,13 +466,13 @@ class SwapIndexGenerator:
455466
(array([7, 0, 4, 2]), array([3, 5, 1, 8]))
456467
"""
457468

458-
def __init__(self, rng, n: int):
469+
def __init__(self, rng: np.random.Generator, n: int):
459470
assert n >= 2
460471
self.rng = rng
461472
self.indices = np.arange(n)
462473
self.permutation = self.rng.permutation(self.indices)
463474

464-
def __call__(self, size: int):
475+
def __call__(self, size: int) -> tuple[Array1D, Array1D]:
465476
assert size >= 1
466477

467478
# Get 2 * size elements
@@ -484,12 +495,12 @@ class Permutation(Correlator):
484495
def __init__(
485496
self,
486497
*,
487-
weights=None,
488-
iterations=1000,
489-
tol=0.01,
490-
correlation_type="pearson",
491-
random_state=None,
492-
verbose=False,
498+
weights: Array2D | None = None,
499+
iterations: int = 1000,
500+
tol: float = 0.01,
501+
correlation_type: CorrelationType = "pearson",
502+
random_state: np.random.Generator | int | None = None,
503+
verbose: bool = False,
493504
):
494505
"""Create a Permutation instance, which induces correlations
495506
between variables in X by randomly shuffling rows within each column.
@@ -586,7 +597,12 @@ def __init__(
586597
self.verbose = verbose
587598
self.correlation_type = correlation_type
588599

589-
def set_target(self, correlation_matrix, *, weights=None):
600+
def set_target(
601+
self,
602+
correlation_matrix: Array2D,
603+
*,
604+
weights: Array2D | None = None,
605+
) -> Self:
590606
"""Set the target correlation matrix.
591607
592608
Parameters
@@ -603,14 +619,14 @@ def set_target(self, correlation_matrix, *, weights=None):
603619
self.triu_indices = np.triu_indices(self.C.shape[0], k=1)
604620
return self
605621

606-
def _error(self, observed, target):
622+
def _error(self, observed: Array2D, target: Array2D) -> float:
607623
"""Compute RMSE over upper triangular part of corr(X) - target."""
608624
idx = self.triu_indices # Get upper triangular indices (ignore diag)
609625
weighted_residuals_sq = self.weights[idx] * (observed[idx] - target[idx]) ** 2.0
610626
return float(np.sqrt(np.sum(weighted_residuals_sq)))
611627

612628
@staticmethod
613-
def subiters(n, i):
629+
def subiters(n: int, i: int) -> int:
614630
"""Number of sub-iterations (swaps) per iteration."""
615631
# Use longer swap lengths in early iterations. The last half
616632
# of the iterations will use 1 sub-iteration. The second half of the
@@ -624,7 +640,7 @@ def subiters(n, i):
624640
C = np.log2(n) + 1
625641
return int(np.ceil(C ** (1 - (2 * i / n))))
626642

627-
def __call__(self, X):
643+
def __call__(self, X: Array2D) -> Array2D:
628644
"""Cycle through through columns (variables), and for each
629645
column it swaps random rows (observations). If the result
630646
leads to a smaller error (correlation closer to target), then it is
@@ -654,7 +670,9 @@ def __call__(self, X):
654670
f"Running permutation correlator for {self.iters if self.iters else 'inf'} iterations."
655671
)
656672

657-
def product(iterations_gen, variables_gen):
673+
def product(
674+
iterations_gen: Iterable[int], variables_gen: Iterable[int]
675+
) -> Iterator[tuple[int, int]]:
658676
# itertools.product only works for finite inputs, so we need this
659677
for i in iterations_gen:
660678
for j in variables_gen:
@@ -711,7 +729,7 @@ def product(iterations_gen, variables_gen):
711729
return corr_mat.X # The permuted data stored in CorrelationMatrix
712730

713731

714-
def decorrelate(X, remove_variance=True):
732+
def decorrelate(X: Array2D, remove_variance: bool = True) -> Array2D:
715733
"""Removes correlations or covariance from data X.
716734
717735
Examples
@@ -825,7 +843,12 @@ class CorrelationMatrix:
825843
[ 0.325, -0.6 , -0.151, 1. ]])
826844
"""
827845

828-
def __init__(self, X, correlation_type="pearson", check=True):
846+
def __init__(
847+
self,
848+
X: Array2D,
849+
correlation_type: CorrelationType = "pearson",
850+
check: bool = True,
851+
):
829852
valid_corrs = ("pearson", "spearman")
830853
assert correlation_type in valid_corrs
831854
assert X.ndim == 2
@@ -860,13 +883,18 @@ def __init__(self, X, correlation_type="pearson", check=True):
860883
:, None
861884
]
862885

863-
def __repr__(self):
886+
def __repr__(self) -> str:
864887
return repr(self.corr_mat)
865888

866-
def __getitem__(self, *args, **kwargs):
889+
def __getitem__(self, *args: Any, **kwargs: Any) -> float:
867890
return self.corr_mat.__getitem__(*args, **kwargs)
868891

869-
def commit(self, col, i, j):
892+
def commit(
893+
self,
894+
col: int,
895+
i: int | list[int],
896+
j: int | list[int],
897+
) -> Self:
870898
"""Commit a swap, storing new data and new correlation matrix."""
871899

872900
# Compute everything we need to update internal state
@@ -887,7 +915,12 @@ def commit(self, col, i, j):
887915
self.X[i, col], self.X[j, col] = self.X[j, col], self.X[i, col]
888916
return self
889917

890-
def _delta_numerator(self, col, i, j):
918+
def _delta_numerator(
919+
self,
920+
col: int,
921+
i: int | list[int],
922+
j: int | list[int],
923+
) -> Array1D:
891924
"""Compute the delta in the numerator when swapping."""
892925
if self.check:
893926
assert isinstance(col, int)
@@ -914,14 +947,24 @@ def _delta_numerator(self, col, i, j):
914947
delta_numerator[col] = 0.0
915948
return delta_numerator
916949

917-
def delta_column(self, col, i, j):
950+
def delta_column(
951+
self,
952+
col: int,
953+
i: int | list[int],
954+
j: int | list[int],
955+
) -> Array1D:
918956
"""Returns the change in the column `col` in the correlation matrix
919957
when rows i and j are swapped. To save a change, use `.commit()`."""
920958

921959
diff = self._delta_numerator(col, i, j)
922960
return diff / (self.m * self.denominator * self.denominator[col])
923961

924-
def update_column(self, col, i, j):
962+
def update_column(
963+
self,
964+
col: int,
965+
i: int | list[int],
966+
j: int | list[int],
967+
) -> Array1D:
925968
"""Returns the new value of column `col` in the correlation matrix
926969
when rows i and j are swapped. To save a change, use `.commit()`."""
927970

@@ -933,25 +976,30 @@ def update_column(self, col, i, j):
933976
class Composite(Correlator):
934977
"""A composition where we first run ImanConover, then Permutation."""
935978

936-
def __init__(self, *args, **kwargs):
979+
def __init__(self, *args: Any, **kwargs: Any):
937980
self.iman_conover_correlator = ImanConover()
938981
self.permutation_correlator = Permutation(*args, **kwargs)
939982

940-
def set_target(self, correlation_matrix, *, weights=None):
983+
def set_target(
984+
self,
985+
correlation_matrix: Array2D,
986+
*,
987+
weights: Array2D | None = None,
988+
) -> Self:
941989
self.iman_conover_correlator.set_target(correlation_matrix)
942990
self.permutation_correlator.set_target(correlation_matrix, weights=weights)
943991
return self
944992

945-
def __call__(self, X):
993+
def __call__(self, X: Array2D) -> Array2D:
946994
# First run ImanConover to get a good starting point
947995
X_ic = self.iman_conover_correlator(X)
948996
# Then run Permutation
949997
return self.permutation_correlator(X_ic)
950998

951999

9521000
if __name__ == "__main__":
953-
import pytest
9541001
import matplotlib.pyplot as plt
1002+
import pytest
9551003

9561004
pytest.main(args=[__file__, "--doctest-modules", "-v", "--capture=sys"])
9571005

0 commit comments

Comments
 (0)