Skip to content

Commit 280c90d

Browse files
committed
Add type annotations on input and return values
1 parent fc7e2d5 commit 280c90d

8 files changed

Lines changed: 372 additions & 172 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 & 39 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.")
@@ -244,11 +255,11 @@ class Cholesky(Correlator):
244255
245256
"""
246257

247-
def set_target(self, correlation_matrix):
258+
def set_target(self, correlation_matrix: Array2D) -> Self:
248259
super().set_target(correlation_matrix)
249260
return self
250261

251-
def __call__(self, X):
262+
def __call__(self, X: Array2D) -> Array2D:
252263
"""Transform an input matrix X.
253264
254265
Parameters
@@ -368,11 +379,11 @@ class ImanConover(Correlator):
368379
np.float64(0.592541)
369380
"""
370381

371-
def set_target(self, correlation_matrix):
382+
def set_target(self, correlation_matrix: Array2D) -> Self:
372383
super().set_target(correlation_matrix)
373384
return self
374385

375-
def __call__(self, X):
386+
def __call__(self, X: Array2D) -> Array2D:
376387
"""Transform an input matrix X.
377388
378389
The output will have the same marginal distributions, but with
@@ -453,13 +464,13 @@ class SwapIndexGenerator:
453464
(array([7, 0, 4, 2]), array([3, 5, 1, 8]))
454465
"""
455466

456-
def __init__(self, rng, n: int):
467+
def __init__(self, rng: np.random.Generator, n: int):
457468
assert n >= 2
458469
self.rng = rng
459470
self.indices = np.arange(n)
460471
self.permutation = self.rng.permutation(self.indices)
461472

462-
def __call__(self, size: int):
473+
def __call__(self, size: int) -> tuple[Array1D, Array1D]:
463474
assert size >= 1
464475

465476
# Get 2 * size elements
@@ -482,12 +493,12 @@ class Permutation(Correlator):
482493
def __init__(
483494
self,
484495
*,
485-
weights=None,
486-
iterations=1000,
487-
tol=0.01,
488-
correlation_type="pearson",
489-
random_state=None,
490-
verbose=False,
496+
weights: Array2D | None = None,
497+
iterations: int = 1000,
498+
tol: float = 0.01,
499+
correlation_type: CorrelationType = "pearson",
500+
random_state: np.random.Generator | int | None = None,
501+
verbose: bool = False,
491502
):
492503
"""Create a Permutation instance, which induces correlations
493504
between variables in X by randomly shuffling rows within each column.
@@ -584,7 +595,12 @@ def __init__(
584595
self.verbose = verbose
585596
self.correlation_type = correlation_type
586597

587-
def set_target(self, correlation_matrix, *, weights=None):
598+
def set_target(
599+
self,
600+
correlation_matrix: Array2D,
601+
*,
602+
weights: Array2D | None = None,
603+
) -> Self:
588604
"""Set the target correlation matrix.
589605
590606
Parameters
@@ -601,14 +617,14 @@ def set_target(self, correlation_matrix, *, weights=None):
601617
self.triu_indices = np.triu_indices(self.C.shape[0], k=1)
602618
return self
603619

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

610626
@staticmethod
611-
def subiters(n, i):
627+
def subiters(n: int, i: int) -> int:
612628
"""Number of sub-iterations (swaps) per iteration."""
613629
# Use longer swap lengths in early iterations. The last half
614630
# of the iterations will use 1 sub-iteration. The second half of the
@@ -622,7 +638,7 @@ def subiters(n, i):
622638
C = np.log2(n) + 1
623639
return int(np.ceil(C ** (1 - (2 * i / n))))
624640

625-
def __call__(self, X):
641+
def __call__(self, X: Array2D) -> Array2D:
626642
"""Cycle through through columns (variables), and for each
627643
column it swaps random rows (observations). If the result
628644
leads to a smaller error (correlation closer to target), then it is
@@ -652,7 +668,9 @@ def __call__(self, X):
652668
f"Running permutation correlator for {self.iters if self.iters else 'inf'} iterations."
653669
)
654670

655-
def product(iterations_gen, variables_gen):
671+
def product(
672+
iterations_gen: Iterable[int], variables_gen: Iterable[int]
673+
) -> Iterator[tuple[int, int]]:
656674
# itertools.product only works for finite inputs, so we need this
657675
for i in iterations_gen:
658676
for j in variables_gen:
@@ -709,7 +727,7 @@ def product(iterations_gen, variables_gen):
709727
return corr_mat.X # The permuted data stored in CorrelationMatrix
710728

711729

712-
def decorrelate(X, remove_variance=True):
730+
def decorrelate(X: Array2D, remove_variance: bool = True) -> Array2D:
713731
"""Removes correlations or covariance from data X.
714732
715733
Examples
@@ -823,7 +841,12 @@ class CorrelationMatrix:
823841
[ 0.325, -0.6 , -0.151, 1. ]])
824842
"""
825843

826-
def __init__(self, X, correlation_type="pearson", check=True):
844+
def __init__(
845+
self,
846+
X: Array2D,
847+
correlation_type: CorrelationType = "pearson",
848+
check: bool = True,
849+
):
827850
valid_corrs = ("pearson", "spearman")
828851
assert correlation_type in valid_corrs
829852
assert X.ndim == 2
@@ -858,13 +881,18 @@ def __init__(self, X, correlation_type="pearson", check=True):
858881
:, None
859882
]
860883

861-
def __repr__(self):
884+
def __repr__(self) -> str:
862885
return repr(self.corr_mat)
863886

864-
def __getitem__(self, *args, **kwargs):
887+
def __getitem__(self, *args: Any, **kwargs: Any) -> float:
865888
return self.corr_mat.__getitem__(*args, **kwargs)
866889

867-
def commit(self, col, i, j):
890+
def commit(
891+
self,
892+
col: int,
893+
i: int | list[int],
894+
j: int | list[int],
895+
) -> Self:
868896
"""Commit a swap, storing new data and new correlation matrix."""
869897

870898
# Compute everything we need to update internal state
@@ -885,7 +913,12 @@ def commit(self, col, i, j):
885913
self.X[i, col], self.X[j, col] = self.X[j, col], self.X[i, col]
886914
return self
887915

888-
def _delta_numerator(self, col, i, j):
916+
def _delta_numerator(
917+
self,
918+
col: int,
919+
i: int | list[int],
920+
j: int | list[int],
921+
) -> Array1D:
889922
"""Compute the delta in the numerator when swapping."""
890923
if self.check:
891924
assert isinstance(col, int)
@@ -912,14 +945,24 @@ def _delta_numerator(self, col, i, j):
912945
delta_numerator[col] = 0.0
913946
return delta_numerator
914947

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

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

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

@@ -953,16 +996,21 @@ class Composite(Correlator):
953996
array([-0.23, 1. , -0.25, -0.27, -0.88, -0.24])
954997
"""
955998

956-
def __init__(self, *args, **kwargs):
999+
def __init__(self, *args: Any, **kwargs: Any):
9571000
self.iman_conover_correlator = ImanConover()
9581001
self.permutation_correlator = Permutation(*args, **kwargs)
9591002

960-
def set_target(self, correlation_matrix, *, weights=None):
1003+
def set_target(
1004+
self,
1005+
correlation_matrix: Array2D,
1006+
*,
1007+
weights: Array2D | None = None,
1008+
) -> Self:
9611009
self.iman_conover_correlator.set_target(correlation_matrix)
9621010
self.permutation_correlator.set_target(correlation_matrix, weights=weights)
9631011
return self
9641012

965-
def __call__(self, X):
1013+
def __call__(self, X: Array2D) -> Array2D:
9661014
try:
9671015
# First run ImanConover to get a good starting point
9681016
X_ic = self.iman_conover_correlator(X)
@@ -971,14 +1019,13 @@ def __call__(self, X):
9711019
X_ic = X
9721020
else:
9731021
raise # re-raise if it's a different ValueError
974-
9751022
# Then run Permutation
9761023
return self.permutation_correlator(X_ic)
9771024

9781025

9791026
if __name__ == "__main__":
980-
import pytest
9811027
import matplotlib.pyplot as plt
1028+
import pytest
9821029

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

0 commit comments

Comments
 (0)