opforch 2.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.
- opforch/__init__.py +7 -0
- opforch/core/__init__.py +5 -0
- opforch/core/heap.py +200 -0
- opforch/core/opf.py +213 -0
- opforch/core/subgraph.py +238 -0
- opforch/math/__init__.py +1 -0
- opforch/math/distance.py +501 -0
- opforch/math/general.py +179 -0
- opforch/math/random.py +49 -0
- opforch/models/__init__.py +6 -0
- opforch/models/knn_supervised.py +278 -0
- opforch/models/semi_supervised.py +157 -0
- opforch/models/supervised.py +403 -0
- opforch/models/unsupervised.py +336 -0
- opforch/stream/__init__.py +3 -0
- opforch/stream/loader.py +107 -0
- opforch/stream/parser.py +53 -0
- opforch/stream/splitter.py +128 -0
- opforch/subgraphs/__init__.py +3 -0
- opforch/subgraphs/knn.py +280 -0
- opforch/utils/__init__.py +1 -0
- opforch/utils/constants.py +31 -0
- opforch/utils/converter.py +130 -0
- opforch/utils/device.py +90 -0
- opforch/utils/exception.py +61 -0
- opforch/utils/logging.py +65 -0
- opforch-2.0.0.dist-info/METADATA +229 -0
- opforch-2.0.0.dist-info/RECORD +31 -0
- opforch-2.0.0.dist-info/WHEEL +5 -0
- opforch-2.0.0.dist-info/licenses/LICENSE +177 -0
- opforch-2.0.0.dist-info/top_level.txt +1 -0
opforch/__init__.py
ADDED
opforch/core/__init__.py
ADDED
opforch/core/heap.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Tensor-backed Heap for the Optimum-Path Forest.
|
|
2
|
+
|
|
3
|
+
A binary heap (priority queue) supporting min and max policies.
|
|
4
|
+
Internal state is stored as tensors for consistency with the
|
|
5
|
+
rest of OPForch, though heap operations remain sequential.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
import opforch.utils.constants as c
|
|
13
|
+
from opforch.utils.device import DeviceManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Heap:
|
|
17
|
+
"""A binary heap with tensor-backed storage."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
size: int = 1,
|
|
22
|
+
policy: str = "min",
|
|
23
|
+
device: Optional[str] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Initialization method.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
size: Maximum size of the heap.
|
|
29
|
+
policy: Heap's policy ('min' or 'max').
|
|
30
|
+
device: Target device string.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
if size < 1:
|
|
35
|
+
raise builtins.ValueError("`size` should be > 0")
|
|
36
|
+
if policy not in ("min", "max"):
|
|
37
|
+
raise builtins.ValueError("`policy` should be 'min' or 'max'")
|
|
38
|
+
|
|
39
|
+
self.size = size
|
|
40
|
+
self.policy = policy
|
|
41
|
+
self.device = DeviceManager.resolve(device)
|
|
42
|
+
|
|
43
|
+
self.cost = torch.full(
|
|
44
|
+
(size,), c.FLOAT_MAX, dtype=torch.float64, device=self.device
|
|
45
|
+
)
|
|
46
|
+
self.color = torch.full(
|
|
47
|
+
(size,), c.WHITE, dtype=torch.int8, device=self.device
|
|
48
|
+
)
|
|
49
|
+
self.p = torch.full(
|
|
50
|
+
(size,), -1, dtype=torch.int64, device=self.device
|
|
51
|
+
)
|
|
52
|
+
self.pos = torch.full(
|
|
53
|
+
(size,), -1, dtype=torch.int64, device=self.device
|
|
54
|
+
)
|
|
55
|
+
self.last = -1
|
|
56
|
+
|
|
57
|
+
def is_full(self) -> bool:
|
|
58
|
+
"""Checks if the heap is full."""
|
|
59
|
+
|
|
60
|
+
return self.last == (self.size - 1)
|
|
61
|
+
|
|
62
|
+
def is_empty(self) -> bool:
|
|
63
|
+
"""Checks if the heap is empty."""
|
|
64
|
+
|
|
65
|
+
return self.last == -1
|
|
66
|
+
|
|
67
|
+
def dad(self, i: int) -> int:
|
|
68
|
+
"""Returns the position of the node's parent."""
|
|
69
|
+
|
|
70
|
+
return (i - 1) // 2
|
|
71
|
+
|
|
72
|
+
def left_son(self, i: int) -> int:
|
|
73
|
+
"""Returns the position of the node's left child."""
|
|
74
|
+
|
|
75
|
+
return 2 * i + 1
|
|
76
|
+
|
|
77
|
+
def right_son(self, i: int) -> int:
|
|
78
|
+
"""Returns the position of the node's right child."""
|
|
79
|
+
|
|
80
|
+
return 2 * i + 2
|
|
81
|
+
|
|
82
|
+
def go_up(self, i: int) -> None:
|
|
83
|
+
"""Sifts a node up to maintain heap property.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
i: Position to sift up from.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
j = self.dad(i)
|
|
91
|
+
|
|
92
|
+
if self.policy == "min":
|
|
93
|
+
while i > 0 and self.cost[self.p[j]] > self.cost[self.p[i]]:
|
|
94
|
+
self.p[j], self.p[i] = self.p[i].clone(), self.p[j].clone()
|
|
95
|
+
self.pos[self.p[i]] = i
|
|
96
|
+
self.pos[self.p[j]] = j
|
|
97
|
+
i = j
|
|
98
|
+
j = self.dad(i)
|
|
99
|
+
else:
|
|
100
|
+
while i > 0 and self.cost[self.p[j]] < self.cost[self.p[i]]:
|
|
101
|
+
self.p[j], self.p[i] = self.p[i].clone(), self.p[j].clone()
|
|
102
|
+
self.pos[self.p[i]] = i
|
|
103
|
+
self.pos[self.p[j]] = j
|
|
104
|
+
i = j
|
|
105
|
+
j = self.dad(i)
|
|
106
|
+
|
|
107
|
+
def go_down(self, i: int) -> None:
|
|
108
|
+
"""Sifts a node down to maintain heap property.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
i: Position to sift down from.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
left = self.left_son(i)
|
|
116
|
+
right = self.right_son(i)
|
|
117
|
+
j = i
|
|
118
|
+
|
|
119
|
+
if self.policy == "min":
|
|
120
|
+
if left <= self.last and self.cost[self.p[left]] < self.cost[self.p[i]]:
|
|
121
|
+
j = left
|
|
122
|
+
if right <= self.last and self.cost[self.p[right]] < self.cost[self.p[j]]:
|
|
123
|
+
j = right
|
|
124
|
+
else:
|
|
125
|
+
if left <= self.last and self.cost[self.p[left]] > self.cost[self.p[i]]:
|
|
126
|
+
j = left
|
|
127
|
+
if right <= self.last and self.cost[self.p[right]] > self.cost[self.p[j]]:
|
|
128
|
+
j = right
|
|
129
|
+
|
|
130
|
+
if j != i:
|
|
131
|
+
self.p[j], self.p[i] = self.p[i].clone(), self.p[j].clone()
|
|
132
|
+
self.pos[self.p[i]] = i
|
|
133
|
+
self.pos[self.p[j]] = j
|
|
134
|
+
self.go_down(j)
|
|
135
|
+
|
|
136
|
+
def insert(self, p: int) -> bool:
|
|
137
|
+
"""Inserts a new node into the heap.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
p: Node index to insert.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if insertion succeeded, False if heap is full.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if not self.is_full():
|
|
148
|
+
self.last += 1
|
|
149
|
+
self.p[self.last] = p
|
|
150
|
+
self.color[p] = c.GRAY
|
|
151
|
+
self.pos[p] = self.last
|
|
152
|
+
self.go_up(self.last)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def remove(self) -> int:
|
|
158
|
+
"""Removes and returns the root node from the heap.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The removed node index, or -1 if heap is empty.
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
if not self.is_empty():
|
|
166
|
+
p = self.p[0].item()
|
|
167
|
+
|
|
168
|
+
self.pos[p] = -1
|
|
169
|
+
self.color[p] = c.BLACK
|
|
170
|
+
|
|
171
|
+
self.p[0] = self.p[self.last]
|
|
172
|
+
self.pos[self.p[0]] = 0
|
|
173
|
+
self.p[self.last] = -1
|
|
174
|
+
|
|
175
|
+
self.last -= 1
|
|
176
|
+
|
|
177
|
+
self.go_down(0)
|
|
178
|
+
|
|
179
|
+
return p
|
|
180
|
+
|
|
181
|
+
return -1
|
|
182
|
+
|
|
183
|
+
def update(self, p: int, cost: float) -> None:
|
|
184
|
+
"""Updates a node's cost and adjusts its position.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
p: Node index.
|
|
188
|
+
cost: New cost value.
|
|
189
|
+
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
self.cost[p] = cost
|
|
193
|
+
|
|
194
|
+
if self.color[p] == c.BLACK:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
if self.color[p] == c.WHITE:
|
|
198
|
+
self.insert(p)
|
|
199
|
+
else:
|
|
200
|
+
self.go_up(self.pos[p].item())
|
opforch/core/opf.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Optimum-Path Forest abstract base classifier."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
import opforch.math.distance as d
|
|
9
|
+
import opforch.utils.exception as e
|
|
10
|
+
from opforch.core.subgraph import Subgraph
|
|
11
|
+
from opforch.utils import logging
|
|
12
|
+
from opforch.utils.device import DeviceManager
|
|
13
|
+
|
|
14
|
+
logger = logging.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OPF:
|
|
18
|
+
"""Abstract base class defining all common OPF-related methods.
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
J. P. Papa, A. X. Falcão and C. T. N. Suzuki.
|
|
22
|
+
LibOPF: A library for the design of optimum-path forest classifiers (2015).
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
distance: str = "log_squared_euclidean",
|
|
29
|
+
pre_computed_distance: Optional[str] = None,
|
|
30
|
+
device: Optional[str] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialization method.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
distance: Name of the distance metric to use.
|
|
36
|
+
pre_computed_distance: Path to a pre-computed distance file.
|
|
37
|
+
device: Target device string ('cpu', 'cuda', 'cuda:0', etc.).
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
logger.info("Creating class: OPF.")
|
|
42
|
+
|
|
43
|
+
self.device = DeviceManager.resolve(device)
|
|
44
|
+
|
|
45
|
+
self.subgraph = None
|
|
46
|
+
|
|
47
|
+
if distance not in d.VALID_DISTANCES:
|
|
48
|
+
raise e.TypeError(
|
|
49
|
+
f"`distance` should be one of: {', '.join(sorted(d.VALID_DISTANCES))}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.distance = distance
|
|
53
|
+
self.distance_fn = d.DISTANCES[distance]
|
|
54
|
+
|
|
55
|
+
if pre_computed_distance:
|
|
56
|
+
self.pre_computed_distance = True
|
|
57
|
+
self._read_distances(pre_computed_distance)
|
|
58
|
+
else:
|
|
59
|
+
self.pre_computed_distance = False
|
|
60
|
+
self.pre_distances = None
|
|
61
|
+
|
|
62
|
+
logger.debug(
|
|
63
|
+
"Distance: %s | Pre-computed distance: %s | Device: %s.",
|
|
64
|
+
self.distance,
|
|
65
|
+
self.pre_computed_distance,
|
|
66
|
+
self.device,
|
|
67
|
+
)
|
|
68
|
+
logger.info("Class created.")
|
|
69
|
+
|
|
70
|
+
def _read_distances(self, file_name: str) -> None:
|
|
71
|
+
"""Reads pre-computed distances from a file.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
file_name: Path to the distance file (.csv, .txt, or .pt).
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
logger.debug("Running private method: read_distances().")
|
|
79
|
+
|
|
80
|
+
extension = file_name.split(".")[-1]
|
|
81
|
+
|
|
82
|
+
if extension in ("pt", "pth"):
|
|
83
|
+
distances = torch.load(file_name, map_location=self.device)
|
|
84
|
+
elif extension == "csv":
|
|
85
|
+
import numpy as np
|
|
86
|
+
|
|
87
|
+
data = np.loadtxt(file_name, delimiter=",")
|
|
88
|
+
distances = torch.from_numpy(data).to(
|
|
89
|
+
dtype=torch.float64, device=self.device
|
|
90
|
+
)
|
|
91
|
+
elif extension == "txt":
|
|
92
|
+
import numpy as np
|
|
93
|
+
|
|
94
|
+
data = np.loadtxt(file_name, delimiter=" ")
|
|
95
|
+
distances = torch.from_numpy(data).to(
|
|
96
|
+
dtype=torch.float64, device=self.device
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
raise e.ArgumentError(
|
|
100
|
+
"File extension not recognized. "
|
|
101
|
+
"It should be `.csv`, `.txt`, `.pt` or `.pth`"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if distances is None:
|
|
105
|
+
raise e.ValueError(
|
|
106
|
+
"Pre-computed distances could not be properly loaded"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.pre_distances = distances
|
|
110
|
+
|
|
111
|
+
def get_distances(self, normalize: bool = False) -> torch.Tensor:
|
|
112
|
+
"""Computes the full distance matrix for the training subgraph.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
normalize: Whether to min-max normalize the matrix.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Distance matrix of shape (N, N).
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
distances = self.distance_fn(
|
|
123
|
+
self.subgraph.features, self.subgraph.features
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if normalize:
|
|
127
|
+
d_min = distances.min()
|
|
128
|
+
d_max = distances.max()
|
|
129
|
+
return (distances - d_min) / (d_max - d_min)
|
|
130
|
+
|
|
131
|
+
return distances
|
|
132
|
+
|
|
133
|
+
def to(self, device) -> "OPF":
|
|
134
|
+
"""Moves the entire model to a device.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
device: Target device string or torch.device.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Self, for chaining.
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
self.device = (
|
|
145
|
+
torch.device(device) if isinstance(device, str) else device
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if self.subgraph is not None:
|
|
149
|
+
self.subgraph.to(self.device)
|
|
150
|
+
|
|
151
|
+
if self.pre_distances is not None:
|
|
152
|
+
self.pre_distances = self.pre_distances.to(self.device)
|
|
153
|
+
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def save(self, file_name: str) -> None:
|
|
157
|
+
"""Saves the model using torch.save.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
file_name: File path to save to.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
logger.info("Saving model to file: %s ...", file_name)
|
|
165
|
+
|
|
166
|
+
# Move to CPU before saving for portability
|
|
167
|
+
cpu_model = self.to("cpu")
|
|
168
|
+
torch.save(cpu_model, file_name)
|
|
169
|
+
|
|
170
|
+
logger.info("Model saved.")
|
|
171
|
+
|
|
172
|
+
def load(self, file_name: str) -> None:
|
|
173
|
+
"""Loads a model from a file.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
file_name: File path to load from.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
logger.info("Loading model from file: %s ...", file_name)
|
|
181
|
+
|
|
182
|
+
loaded = torch.load(file_name, map_location=self.device, weights_only=False)
|
|
183
|
+
self.__dict__.update(loaded.__dict__)
|
|
184
|
+
|
|
185
|
+
logger.info("Model loaded.")
|
|
186
|
+
|
|
187
|
+
def fit(self, X: torch.Tensor, Y: torch.Tensor, **kwargs) -> None:
|
|
188
|
+
"""Fits data in the classifier.
|
|
189
|
+
|
|
190
|
+
Must be implemented by subclasses.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
X: Feature tensor of shape (N, D).
|
|
194
|
+
Y: Label tensor of shape (N,).
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
raise NotImplementedError
|
|
199
|
+
|
|
200
|
+
def predict(self, X: torch.Tensor, **kwargs) -> List[int]:
|
|
201
|
+
"""Predicts new data using the pre-trained classifier.
|
|
202
|
+
|
|
203
|
+
Must be implemented by subclasses.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
X: Feature tensor of shape (M, D).
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
A list of predicted labels.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
raise NotImplementedError
|
opforch/core/subgraph.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Tensor-first Subgraph for the Optimum-Path Forest.
|
|
2
|
+
|
|
3
|
+
Replaces the OPFython Node + Subgraph pair with dense tensors.
|
|
4
|
+
All per-node state lives as columns in this class, enabling
|
|
5
|
+
batch operations and seamless GPU transfer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
import opforch.stream.parser as p
|
|
14
|
+
import opforch.utils.constants as c
|
|
15
|
+
import opforch.utils.exception as e
|
|
16
|
+
from opforch.stream import loader
|
|
17
|
+
from opforch.utils import logging
|
|
18
|
+
from opforch.utils.device import DeviceManager
|
|
19
|
+
|
|
20
|
+
logger = logging.get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Subgraph:
|
|
24
|
+
"""A tensor-based collection of nodes forming the OPF subgraph.
|
|
25
|
+
|
|
26
|
+
All per-node attributes are stored as dense 1-D or 2-D tensors,
|
|
27
|
+
enabling vectorized operations and device transfer.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
X: Optional[torch.Tensor] = None,
|
|
33
|
+
Y: Optional[torch.Tensor] = None,
|
|
34
|
+
I: Optional[torch.Tensor] = None,
|
|
35
|
+
from_file: Optional[str] = None,
|
|
36
|
+
device: Optional[str] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialization method.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
X: Feature array/tensor of shape (N, D).
|
|
42
|
+
Y: Label array/tensor of shape (N,).
|
|
43
|
+
I: Index array/tensor of shape (N,).
|
|
44
|
+
from_file: Path to load data from (.csv, .txt, or .json).
|
|
45
|
+
device: Target device string (e.g. 'cpu', 'cuda').
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
self.device = DeviceManager.resolve(device)
|
|
50
|
+
self.trained = False
|
|
51
|
+
|
|
52
|
+
if from_file:
|
|
53
|
+
X, Y = self._load(from_file)
|
|
54
|
+
|
|
55
|
+
if X is not None:
|
|
56
|
+
self._build(X, Y, I)
|
|
57
|
+
else:
|
|
58
|
+
# Empty subgraph — tensors will be initialized later
|
|
59
|
+
self.features = torch.empty(0, device=self.device)
|
|
60
|
+
self.labels = torch.empty(0, dtype=torch.int64, device=self.device)
|
|
61
|
+
self._n_features = 0
|
|
62
|
+
self._init_state_tensors(0)
|
|
63
|
+
logger.error("Subgraph has not been properly created.")
|
|
64
|
+
|
|
65
|
+
def _to_tensor(self, arr, dtype=torch.float32) -> torch.Tensor:
|
|
66
|
+
"""Converts numpy arrays, lists, or existing tensors to the target device."""
|
|
67
|
+
|
|
68
|
+
if arr is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if isinstance(arr, np.ndarray):
|
|
72
|
+
t = torch.from_numpy(arr)
|
|
73
|
+
elif isinstance(arr, torch.Tensor):
|
|
74
|
+
t = arr
|
|
75
|
+
else:
|
|
76
|
+
t = torch.tensor(arr)
|
|
77
|
+
|
|
78
|
+
return t.to(dtype=dtype, device=self.device)
|
|
79
|
+
|
|
80
|
+
def _load(self, file_path: str) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
81
|
+
"""Loads and parses a dataframe from a file.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
file_path: File to be loaded.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (features, labels) tensors.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
extension = file_path.split(".")[-1]
|
|
92
|
+
|
|
93
|
+
if extension == "csv":
|
|
94
|
+
data = loader.load_csv(file_path)
|
|
95
|
+
elif extension == "txt":
|
|
96
|
+
data = loader.load_txt(file_path)
|
|
97
|
+
elif extension == "json":
|
|
98
|
+
data = loader.load_json(file_path)
|
|
99
|
+
else:
|
|
100
|
+
raise e.ArgumentError(
|
|
101
|
+
"File extension not recognized. It should be `.csv`, `.json` or `.txt`"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
X, Y = p.parse_loader(data)
|
|
105
|
+
|
|
106
|
+
return X, Y
|
|
107
|
+
|
|
108
|
+
def _init_state_tensors(self, n: int) -> None:
|
|
109
|
+
"""Initializes all per-node state tensors to default values.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
n: Number of nodes.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
dev = self.device
|
|
117
|
+
|
|
118
|
+
self.pred_labels = torch.zeros(n, dtype=torch.int64, device=dev)
|
|
119
|
+
self.cluster_labels = torch.zeros(n, dtype=torch.int64, device=dev)
|
|
120
|
+
self.costs = torch.zeros(n, dtype=torch.float64, device=dev)
|
|
121
|
+
self.densities = torch.zeros(n, dtype=torch.float64, device=dev)
|
|
122
|
+
self.radii = torch.zeros(n, dtype=torch.float64, device=dev)
|
|
123
|
+
self.n_plateaus = torch.zeros(n, dtype=torch.int64, device=dev)
|
|
124
|
+
self.preds = torch.full((n,), c.NIL, dtype=torch.int64, device=dev)
|
|
125
|
+
self.roots = torch.arange(n, dtype=torch.int64, device=dev)
|
|
126
|
+
self.status = torch.full((n,), c.STANDARD, dtype=torch.int8, device=dev)
|
|
127
|
+
self.relevant = torch.full((n,), c.IRRELEVANT, dtype=torch.int8, device=dev)
|
|
128
|
+
|
|
129
|
+
# Ordered node indices (filled during training)
|
|
130
|
+
self.idx_nodes = []
|
|
131
|
+
|
|
132
|
+
# Adjacency (set by KNNSubgraph or during plateau expansion)
|
|
133
|
+
self.adjacency = None
|
|
134
|
+
|
|
135
|
+
def _build(
|
|
136
|
+
self,
|
|
137
|
+
X: torch.Tensor,
|
|
138
|
+
Y: Optional[torch.Tensor],
|
|
139
|
+
I: Optional[torch.Tensor],
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Builds the subgraph from feature/label/index data.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
X: Features of shape (N, D).
|
|
145
|
+
Y: Labels of shape (N,). Defaults to zeros if None.
|
|
146
|
+
I: Original indices of shape (N,). Defaults to 0..N-1 if None.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
self.features = self._to_tensor(X, dtype=torch.float32)
|
|
151
|
+
|
|
152
|
+
n = self.features.shape[0]
|
|
153
|
+
|
|
154
|
+
if Y is not None:
|
|
155
|
+
self.labels = self._to_tensor(Y, dtype=torch.int64)
|
|
156
|
+
else:
|
|
157
|
+
self.labels = torch.zeros(n, dtype=torch.int64, device=self.device)
|
|
158
|
+
|
|
159
|
+
if I is not None:
|
|
160
|
+
self.indices = self._to_tensor(I, dtype=torch.int64)
|
|
161
|
+
else:
|
|
162
|
+
self.indices = torch.arange(n, dtype=torch.int64, device=self.device)
|
|
163
|
+
|
|
164
|
+
self._n_features = self.features.shape[1] if self.features.dim() > 1 else 0
|
|
165
|
+
|
|
166
|
+
self._init_state_tensors(n)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def n_nodes(self) -> int:
|
|
170
|
+
"""Number of nodes in the subgraph."""
|
|
171
|
+
|
|
172
|
+
return self.features.shape[0]
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def n_features(self) -> int:
|
|
176
|
+
"""Dimensionality of the feature space."""
|
|
177
|
+
|
|
178
|
+
return self._n_features
|
|
179
|
+
|
|
180
|
+
def to(self, device) -> "Subgraph":
|
|
181
|
+
"""Moves all tensors to the specified device.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
device: Target torch device or string.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Self, for chaining.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
device = torch.device(device) if isinstance(device, str) else device
|
|
192
|
+
self.device = device
|
|
193
|
+
|
|
194
|
+
self.features = self.features.to(device)
|
|
195
|
+
self.labels = self.labels.to(device)
|
|
196
|
+
self.indices = self.indices.to(device)
|
|
197
|
+
self.pred_labels = self.pred_labels.to(device)
|
|
198
|
+
self.cluster_labels = self.cluster_labels.to(device)
|
|
199
|
+
self.costs = self.costs.to(device)
|
|
200
|
+
self.densities = self.densities.to(device)
|
|
201
|
+
self.radii = self.radii.to(device)
|
|
202
|
+
self.n_plateaus = self.n_plateaus.to(device)
|
|
203
|
+
self.preds = self.preds.to(device)
|
|
204
|
+
self.roots = self.roots.to(device)
|
|
205
|
+
self.status = self.status.to(device)
|
|
206
|
+
self.relevant = self.relevant.to(device)
|
|
207
|
+
|
|
208
|
+
if self.adjacency is not None:
|
|
209
|
+
self.adjacency = self.adjacency.to(device)
|
|
210
|
+
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def destroy_arcs(self) -> None:
|
|
214
|
+
"""Destroys all adjacency arcs in the subgraph."""
|
|
215
|
+
|
|
216
|
+
self.n_plateaus.zero_()
|
|
217
|
+
self.adjacency = None
|
|
218
|
+
|
|
219
|
+
def mark_nodes(self, i: int) -> None:
|
|
220
|
+
"""Marks a node and its entire predecessor chain as relevant.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
i: Starting node index.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
while self.preds[i].item() != c.NIL:
|
|
228
|
+
self.relevant[i] = c.RELEVANT
|
|
229
|
+
i = self.preds[i].item()
|
|
230
|
+
|
|
231
|
+
self.relevant[i] = c.RELEVANT
|
|
232
|
+
|
|
233
|
+
def reset(self) -> None:
|
|
234
|
+
"""Resets predecessors, relevance flags, and arcs."""
|
|
235
|
+
|
|
236
|
+
self.preds.fill_(c.NIL)
|
|
237
|
+
self.relevant.fill_(c.IRRELEVANT)
|
|
238
|
+
self.destroy_arcs()
|
opforch/math/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mathematical package for OPForch."""
|