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 ADDED
@@ -0,0 +1,7 @@
1
+ """OPForch: PyTorch-Inspired Optimum-Path Forest Classifier.
2
+
3
+ This library provides GPU-accelerated OPF classifiers
4
+ using PyTorch tensors as the universal data representation.
5
+ """
6
+
7
+ __version__ = "2.0.0"
@@ -0,0 +1,5 @@
1
+ """Core structures for OPForch implementations."""
2
+
3
+ from opforch.core.heap import Heap
4
+ from opforch.core.subgraph import Subgraph
5
+ from opforch.core.opf import OPF
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
@@ -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()
@@ -0,0 +1 @@
1
+ """Mathematical package for OPForch."""