nystrom-ncut 0.2.2__py3-none-any.whl → 0.3.1__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.
nystrom_ncut/common.py CHANGED
@@ -12,8 +12,8 @@ def ceildiv(a: int, b: int) -> int:
12
12
  def lazy_normalize(x: torch.Tensor, n: int = 1000, **normalize_kwargs: Any) -> torch.Tensor:
13
13
  numel = np.prod(x.shape[:-1])
14
14
  n = min(n, numel)
15
- random_indices = torch.randperm(numel)[:n]
16
- _x = x.flatten(0, -2)[random_indices]
15
+ random_indices = torch.randperm(numel, device=x.device)[:n]
16
+ _x = x.view((-1, x.shape[-1]))[random_indices]
17
17
  if torch.allclose(torch.norm(_x, **normalize_kwargs), torch.ones(n, device=x.device)):
18
18
  return x
19
19
  else:
@@ -21,13 +21,14 @@ def lazy_normalize(x: torch.Tensor, n: int = 1000, **normalize_kwargs: Any) -> t
21
21
 
22
22
 
23
23
  def quantile_min_max(x: torch.Tensor, q1: float, q2: float, n_sample: int = 10000):
24
- if x.shape[0] > n_sample:
24
+ x = x.flatten()
25
+ if len(x) > n_sample:
25
26
  np.random.seed(0)
26
- random_idx = np.random.choice(x.shape[0], n_sample, replace=False)
27
+ random_idx = np.random.choice(len(x), n_sample, replace=False)
27
28
  vmin, vmax = x[random_idx].quantile(q1), x[random_idx].quantile(q2)
28
29
  else:
29
30
  vmin, vmax = x.quantile(q1), x.quantile(q2)
30
- return vmin, vmax
31
+ return vmin.item(), vmax.item()
31
32
 
32
33
 
33
34
  def quantile_normalize(x: torch.Tensor, q: float = 0.95):
@@ -57,5 +58,17 @@ def quantile_normalize(x: torch.Tensor, q: float = 0.95):
57
58
  return x
58
59
 
59
60
 
61
+ class default_device:
62
+ def __init__(self, device: torch.device):
63
+ self._device = device
64
+
65
+ def __enter__(self):
66
+ self._original_device = torch.get_default_device()
67
+ torch.set_default_device(self._device)
68
+
69
+ def __exit__(self, exc_type, exc_val, exc_tb):
70
+ torch.set_default_device(self._original_device)
71
+
72
+
60
73
  def profile(name: str, t: torch.Tensor) -> None:
61
74
  print(f"{name} --- nan: {t.isnan().any()}, inf: {t.isinf().any()}, max: {t.abs().max()}, min: {t.abs().min()}")
@@ -1,61 +1,71 @@
1
- from typing import Literal
1
+ import collections
2
+ from typing import List, Literal, OrderedDict
2
3
 
3
4
  import torch
4
5
 
5
6
  from .common import lazy_normalize
6
7
 
7
8
 
8
- DistanceOptions = Literal["cosine", "euclidean", "rbf"]
9
+ DistanceOptions = Literal["cosine", "euclidean"]
10
+ AffinityOptions = Literal["cosine", "rbf", "laplacian"]
9
11
 
12
+ # noinspection PyTypeChecker
13
+ DISTANCE_TO_AFFINITY: OrderedDict[DistanceOptions, List[AffinityOptions]] = collections.OrderedDict([
14
+ ("cosine", ["cosine"]),
15
+ ("euclidean", ["rbf", "laplacian"]),
16
+ ])
17
+ # noinspection PyTypeChecker
18
+ AFFINITY_TO_DISTANCE: OrderedDict[AffinityOptions, DistanceOptions] = collections.OrderedDict(sum([
19
+ [(affinity_type, distance_type) for affinity_type in affinity_types]
20
+ for distance_type, affinity_types in DISTANCE_TO_AFFINITY.items()
21
+ ], start=[]))
10
22
 
11
- def to_euclidean(x: torch.Tensor, disttype: DistanceOptions) -> torch.Tensor:
12
- if disttype == "cosine":
23
+
24
+
25
+ def to_euclidean(x: torch.Tensor, distance_type: DistanceOptions) -> torch.Tensor:
26
+ if distance_type == "cosine":
13
27
  return lazy_normalize(x, p=2, dim=-1)
14
- elif disttype == "rbf":
28
+ elif distance_type == "euclidean":
15
29
  return x
16
30
  else:
17
- raise ValueError(f"to_euclidean not implemented for disttype {disttype}.")
31
+ raise ValueError(f"to_euclidean not implemented for distance_type {distance_type}.")
18
32
 
19
33
 
20
34
  def distance_from_features(
21
35
  features: torch.Tensor,
22
36
  features_B: torch.Tensor,
23
- distance: DistanceOptions,
37
+ distance_type: DistanceOptions,
24
38
  ):
25
- """Compute affinity matrix from input features.
39
+ """Compute distance matrix from input features.
26
40
  Args:
27
41
  features (torch.Tensor): input features, shape (n_samples, n_features)
28
42
  features_B (torch.Tensor, optional): optional, if not None, compute affinity between two features
29
- distance (str): distance metric, 'cosine' (default) or 'euclidean', 'rbf'.
43
+ distance_type (str): distance metric, 'cosine' (default) or 'euclidean', 'rbf'.
30
44
  Returns:
31
45
  (torch.Tensor): affinity matrix, shape (n_samples, n_samples)
32
46
  """
33
47
  # compute distance matrix from input features
34
- if distance == "cosine":
35
- features = lazy_normalize(features, dim=-1)
36
- features_B = lazy_normalize(features_B, dim=-1)
37
- D = 1 - features @ features_B.T
38
- elif distance == "euclidean":
39
- D = torch.cdist(features, features_B, p=2)
40
- elif distance == "rbf":
41
- D = 0.5 * torch.cdist(features, features_B, p=2) ** 2
42
-
43
- # Outlier-robust scale invariance using quantiles to estimate standard deviation
44
- c = 2.0
45
- p = torch.erf(torch.tensor((-c, c), device=features.device) * (2 ** -0.5))
46
- stds = torch.quantile(features, q=(p + 1) / 2, dim=0)
47
- stds = (stds[1] - stds[0]) / (2 * c)
48
- D = D / (torch.linalg.norm(stds) ** 2)
49
- else:
50
- raise ValueError("distance should be 'cosine' or 'euclidean', 'rbf'")
51
- return D
48
+ shape: torch.Size = features.shape[:-2]
49
+ features = features.view((-1, *features.shape[-2:]))
50
+ features_B = features_B.view((-1, *features_B.shape[-2:]))
51
+
52
+ match distance_type:
53
+ case "cosine":
54
+ features = lazy_normalize(features, dim=-1)
55
+ features_B = lazy_normalize(features_B, dim=-1)
56
+ D = 1 - features @ features_B.mT
57
+ case "euclidean":
58
+ D = torch.cdist(features, features_B, p=2)
59
+ case _:
60
+ raise ValueError("Distance should be 'cosine' or 'euclidean'")
61
+ return D.view((*shape, *D.shape[-2:]))
52
62
 
53
63
 
54
64
  def affinity_from_features(
55
65
  features: torch.Tensor,
56
66
  features_B: torch.Tensor = None,
57
67
  affinity_focal_gamma: float = 1.0,
58
- distance: DistanceOptions = "cosine",
68
+ affinity_type: AffinityOptions = "cosine",
59
69
  ):
60
70
  """Compute affinity matrix from input features.
61
71
 
@@ -64,7 +74,7 @@ def affinity_from_features(
64
74
  features_B (torch.Tensor, optional): optional, if not None, compute affinity between two features
65
75
  affinity_focal_gamma (float): affinity matrix parameter, lower t reduce the edge weights
66
76
  on weak connections, default 1.0
67
- distance (str): distance metric, 'cosine' (default) or 'euclidean', 'rbf'.
77
+ affinity_type (str): distance metric, 'cosine' (default) or 'euclidean'.
68
78
  Returns:
69
79
  (torch.Tensor): affinity matrix, shape (n_samples, n_samples)
70
80
  """
@@ -75,9 +85,21 @@ def affinity_from_features(
75
85
  features_B = features if features_B is None else features_B
76
86
 
77
87
  # compute distance matrix from input features
78
- D = distance_from_features(features, features_B, distance)
88
+ D = distance_from_features(features, features_B, AFFINITY_TO_DISTANCE[affinity_type])
79
89
 
80
- # torch.exp make affinity matrix positive definite,
81
90
  # lower affinity_focal_gamma reduce the weak edge weights
82
- A = torch.exp(-D / affinity_focal_gamma)
91
+ match affinity_type:
92
+ case "cosine" | "laplacian":
93
+ A = torch.exp(-D / affinity_focal_gamma) # [... x n x n]
94
+ case "rbf":
95
+ # Outlier-robust scale invariance using quantiles to estimate standard deviation
96
+ c = 2.0
97
+ p = torch.erf(torch.tensor((-c, c), device=features.device) * (2 ** -0.5))
98
+ stds = torch.nanquantile(features, q=(p + 1) / 2, dim=-2) # [2 x ... x d]
99
+ stds = (stds[1] - stds[0]) / (2 * c) # [... x d]
100
+ D = 0.5 * (D / torch.norm(stds, dim=-1)[..., None, None]) ** 2
101
+ A = torch.exp(-D / affinity_focal_gamma)
102
+ case _:
103
+ raise ValueError("Affinity should be 'cosine', 'rbf', or 'laplacian'")
104
+
83
105
  return A
@@ -1,6 +1,3 @@
1
- from .distance_realization import (
2
- DistanceRealization,
3
- )
4
1
  from .normalized_cut import (
5
2
  NCut,
6
3
  )
@@ -18,10 +18,10 @@ from ..sampling_utils import (
18
18
  class GramKernel(OnlineKernel):
19
19
  def __init__(
20
20
  self,
21
- distance: DistanceOptions,
21
+ distance_type: DistanceOptions,
22
22
  eig_solver: EigSolverOptions,
23
23
  ):
24
- self.distance: DistanceOptions = distance
24
+ self.distance_type: DistanceOptions = distance_type
25
25
  self.eig_solver: EigSolverOptions = eig_solver
26
26
 
27
27
  # Anchor matrices
@@ -40,7 +40,7 @@ class GramKernel(OnlineKernel):
40
40
  self.A = -0.5 * distance_from_features(
41
41
  self.anchor_features, # [n x d]
42
42
  self.anchor_features,
43
- distance=self.distance,
43
+ distance_type=self.distance_type,
44
44
  ) # [n x n]
45
45
  d = features.shape[-1]
46
46
  U, L = solve_eig(
@@ -58,7 +58,7 @@ class GramKernel(OnlineKernel):
58
58
  B = -0.5 * distance_from_features(
59
59
  self.anchor_features, # [n x d]
60
60
  features, # [m x d]
61
- distance=self.distance,
61
+ distance_type=self.distance_type,
62
62
  ) # [n x m]
63
63
  b_r = torch.sum(B, dim=-1) # [n]
64
64
  b_c = torch.sum(B, dim=-2) # [m]
@@ -84,7 +84,7 @@ class GramKernel(OnlineKernel):
84
84
  B = -0.5 * distance_from_features(
85
85
  self.anchor_features,
86
86
  features,
87
- distance=self.distance,
87
+ distance_type=self.distance_type,
88
88
  )
89
89
  b_c = torch.sum(B, dim=-2) # [m]
90
90
  col_sum = b_c + B.mT @ self.Ainv @ self.b_r # [m]
@@ -98,7 +98,7 @@ class DistanceRealization(OnlineNystromSubsampleFit):
98
98
  def __init__(
99
99
  self,
100
100
  n_components: int = 100,
101
- distance: DistanceOptions = "cosine",
101
+ distance_type: DistanceOptions = "cosine",
102
102
  sample_config: SampleConfig = SampleConfig(),
103
103
  eig_solver: EigSolverOptions = "svd_lowrank",
104
104
  chunk_size: int = 8192,
@@ -115,13 +115,12 @@ class DistanceRealization(OnlineNystromSubsampleFit):
115
115
  OnlineNystromSubsampleFit.__init__(
116
116
  self,
117
117
  n_components=n_components,
118
- kernel=GramKernel(distance, eig_solver),
119
- distance=distance,
118
+ kernel=GramKernel(distance_type, eig_solver),
119
+ distance_type=distance_type,
120
120
  sample_config=sample_config,
121
121
  eig_solver=eig_solver,
122
122
  chunk_size=chunk_size,
123
123
  )
124
- self.distance: DistanceOptions = distance
125
124
 
126
125
  def fit_transform(
127
126
  self,
@@ -8,7 +8,8 @@ from .nystrom_utils import (
8
8
  solve_eig,
9
9
  )
10
10
  from ..distance_utils import (
11
- DistanceOptions,
11
+ AffinityOptions,
12
+ AFFINITY_TO_DISTANCE,
12
13
  affinity_from_features,
13
14
  )
14
15
  from ..sampling_utils import (
@@ -20,80 +21,83 @@ class LaplacianKernel(OnlineKernel):
20
21
  def __init__(
21
22
  self,
22
23
  affinity_focal_gamma: float,
23
- distance: DistanceOptions,
24
+ affinity_type: AffinityOptions,
24
25
  adaptive_scaling: bool,
25
26
  eig_solver: EigSolverOptions,
26
27
  ):
27
28
  self.affinity_focal_gamma = affinity_focal_gamma
28
- self.distance: DistanceOptions = distance
29
+ self.affinity_type: AffinityOptions = affinity_type
29
30
  self.adaptive_scaling: bool = adaptive_scaling
30
31
  self.eig_solver: EigSolverOptions = eig_solver
31
32
 
32
33
  # Anchor matrices
33
- self.anchor_features: torch.Tensor = None # [n x d]
34
- self.A: torch.Tensor = None # [n x n]
35
- self.Ainv: torch.Tensor = None # [n x n]
34
+ self.anchor_features: torch.Tensor = None # [... x n x d]
35
+ self.anchor_mask: torch.Tensor = None
36
+ self.A: torch.Tensor = None # [... x n x n]
37
+ self.Ainv: torch.Tensor = None # [... x n x n]
36
38
 
37
39
  # Updated matrices
38
- self.a_r: torch.Tensor = None # [n]
39
- self.b_r: torch.Tensor = None # [n]
40
+ self.a_r: torch.Tensor = None # [... x n]
41
+ self.b_r: torch.Tensor = None # [... x n]
40
42
 
41
43
  def fit(self, features: torch.Tensor) -> None:
42
- self.anchor_features = features # [n x d]
43
- self.A = affinity_from_features(
44
- self.anchor_features, # [n x d]
44
+ self.anchor_features = features # [... x n x d]
45
+ self.anchor_mask = torch.all(torch.isnan(self.anchor_features), dim=-1) # [... x n]
46
+
47
+ self.A = torch.nan_to_num(affinity_from_features(
48
+ self.anchor_features, # [... x n x d]
45
49
  affinity_focal_gamma=self.affinity_focal_gamma,
46
- distance=self.distance,
47
- ) # [n x n]
50
+ affinity_type=self.affinity_type,
51
+ ), nan=0.0) # [... x n x n]
48
52
  d = features.shape[-1]
49
53
  U, L = solve_eig(
50
54
  self.A,
51
55
  num_eig=d + 1, # d * (d + 3) // 2 + 1,
52
56
  eig_solver=self.eig_solver,
53
- ) # [n x (d + 1)], [d + 1]
54
- self.Ainv = U @ torch.diag(1 / L) @ U.mT # [n x n]
55
- self.a_r = torch.sum(self.A, dim=-1) # [n]
56
- self.b_r = torch.zeros_like(self.a_r) # [n]
57
+ ) # [... x n x (d + 1)], [... x (d + 1)]
58
+ self.Ainv = U @ torch.diag_embed(1 / L) @ U.mT # [... x n x n]
59
+ self.a_r = torch.where(self.anchor_mask, torch.inf, torch.sum(self.A, dim=-1)) # [... x n]
60
+ self.b_r = torch.zeros_like(self.a_r) # [... x n]
57
61
 
58
62
  def _affinity(self, features: torch.Tensor) -> torch.Tensor:
59
- B = affinity_from_features(
60
- self.anchor_features, # [n x d]
61
- features, # [m x d]
63
+ B = torch.where(self.anchor_mask[..., None], 0.0, affinity_from_features(
64
+ self.anchor_features, # [... x n x d]
65
+ features, # [... x m x d]
62
66
  affinity_focal_gamma=self.affinity_focal_gamma,
63
- distance=self.distance,
64
- ) # [n x m]
67
+ affinity_type=self.affinity_type,
68
+ )) # [... x n x m]
65
69
  if self.adaptive_scaling:
66
70
  diagonal = (
67
- einops.rearrange(B, "n m -> m 1 n") # [m x 1 x n]
68
- @ self.Ainv # [n x n]
69
- @ einops.rearrange(B, "n m -> m n 1") # [m x n x 1]
70
- ).squeeze(1, 2) # [m]
71
- adaptive_scale = diagonal ** -0.5 # [m]
72
- B = B * adaptive_scale
73
- return B # [n x m]
71
+ einops.rearrange(B, "... n m -> ... m 1 n") # [... x m x 1 x n]
72
+ @ self.Ainv # [... x n x n]
73
+ @ einops.rearrange(B, "... n m -> ... m n 1") # [... x m x n x 1]
74
+ ).squeeze(-2, -1) # [... x m]
75
+ adaptive_scale = diagonal ** -0.5 # [... x m]
76
+ B = B * adaptive_scale[..., None, :]
77
+ return B # [... x n x m]
74
78
 
75
79
  def update(self, features: torch.Tensor) -> torch.Tensor:
76
- B = self._affinity(features) # [n x m]
77
- b_r = torch.sum(B, dim=-1) # [n]
78
- b_c = torch.sum(B, dim=-2) # [m]
79
- self.b_r = self.b_r + b_r # [n]
80
+ B = self._affinity(features) # [... x n x m]
81
+ b_r = torch.sum(torch.nan_to_num(B, nan=0.0), dim=-1) # [... x n]
82
+ b_c = torch.sum(B, dim=-2) # [... x m]
83
+ self.b_r = self.b_r + b_r # [... x n]
80
84
 
81
- row_sum = self.a_r + self.b_r # [n]
82
- col_sum = b_c + B.mT @ self.Ainv @ self.b_r # [m]
83
- scale = (row_sum[:, None] * col_sum) ** -0.5 # [n x m]
84
- return (B * scale).mT # [m x n]
85
+ row_sum = self.a_r + self.b_r # [... x n]
86
+ col_sum = b_c + (B.mT @ (self.Ainv @ self.b_r[..., None]))[..., 0] # [... x m]
87
+ scale = (row_sum[..., :, None] * col_sum[..., None, :]) ** -0.5 # [... x n x m]
88
+ return (B * scale).mT # [... x m x n]
85
89
 
86
90
  def transform(self, features: torch.Tensor = None) -> torch.Tensor:
87
- row_sum = self.a_r + self.b_r # [n]
91
+ row_sum = self.a_r + self.b_r # [... x n]
88
92
  if features is None:
89
- B = self.A # [n x n]
90
- col_sum = row_sum # [n]
93
+ B = self.A # [... x n x n]
94
+ col_sum = row_sum # [... x n]
91
95
  else:
92
96
  B = self._affinity(features)
93
- b_c = torch.sum(B, dim=-2) # [m]
94
- col_sum = b_c + B.mT @ self.Ainv @ self.b_r # [m]
95
- scale = (row_sum[:, None] * col_sum) ** -0.5 # [n x m]
96
- return (B * scale).mT # [m x n]
97
+ b_c = torch.sum(B, dim=-2) # [... x m]
98
+ col_sum = b_c + (B.mT @ (self.Ainv @ self.b_r[..., None]))[..., 0] # [... x m]
99
+ scale = (row_sum[..., :, None] * col_sum[..., None, :]) ** -0.5 # [... x n x m]
100
+ return (B * scale).mT # [... x m x n]
97
101
 
98
102
 
99
103
  class NCut(OnlineNystromSubsampleFit):
@@ -103,7 +107,7 @@ class NCut(OnlineNystromSubsampleFit):
103
107
  self,
104
108
  n_components: int = 100,
105
109
  affinity_focal_gamma: float = 1.0,
106
- distance: DistanceOptions = "cosine",
110
+ affinity_type: AffinityOptions = "cosine",
107
111
  adaptive_scaling: bool = False,
108
112
  sample_config: SampleConfig = SampleConfig(),
109
113
  eig_solver: EigSolverOptions = "svd_lowrank",
@@ -124,8 +128,8 @@ class NCut(OnlineNystromSubsampleFit):
124
128
  OnlineNystromSubsampleFit.__init__(
125
129
  self,
126
130
  n_components=n_components,
127
- kernel=LaplacianKernel(affinity_focal_gamma, distance, adaptive_scaling, eig_solver),
128
- distance=distance,
131
+ kernel=LaplacianKernel(affinity_focal_gamma, affinity_type, adaptive_scaling, eig_solver),
132
+ distance_type=AFFINITY_TO_DISTANCE[affinity_type],
129
133
  sample_config=sample_config,
130
134
  eig_solver=eig_solver,
131
135
  chunk_size=chunk_size,