edrft 0.1.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.
edrft/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Random Vector Functional Link Transformer models."""
2
+
3
+ from .data import chronological_split, make_forecasting_frame
4
+ from .metrics import mean_absolute_scaled_error, mean_absolute_percentage_error, root_mean_squared_error
5
+ from .models import EDRFTRegressor, RFTLayerParams, RFTRegressor
6
+
7
+ __all__ = [
8
+ "EDRFTRegressor",
9
+ "RFTLayerParams",
10
+ "RFTRegressor",
11
+ "chronological_split",
12
+ "make_forecasting_frame",
13
+ "mean_absolute_percentage_error",
14
+ "mean_absolute_scaled_error",
15
+ "root_mean_squared_error",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
edrft/data.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+
6
+ import numpy as np
7
+
8
+
9
+ def make_forecasting_frame(
10
+ series: Iterable[float] | np.ndarray,
11
+ order: int = 48,
12
+ horizon: int = 4,
13
+ ) -> tuple[np.ndarray, np.ndarray]:
14
+ """Convert a univariate or multivariate sequence into lagged samples."""
15
+
16
+ values = np.asarray(series, dtype=np.float32)
17
+ if values.ndim == 1:
18
+ values = values.reshape(-1, 1)
19
+ if values.ndim != 2:
20
+ raise ValueError("series must be a 1D or 2D array.")
21
+ if order <= 0 or horizon <= 0:
22
+ raise ValueError("order and horizon must be positive.")
23
+ n_samples = values.shape[0] - order - horizon + 1
24
+ if n_samples <= 0:
25
+ raise ValueError("series is too short for the requested order and horizon.")
26
+ X = np.zeros((n_samples, values.shape[1] * order), dtype=np.float32)
27
+ y = np.zeros((n_samples, values.shape[1]), dtype=np.float32)
28
+ for i in range(n_samples):
29
+ X[i] = values[i : i + order].ravel()
30
+ y[i] = values[i + order + horizon - 1]
31
+ return X, y.ravel() if y.shape[1] == 1 else y
32
+
33
+
34
+ def chronological_split(n_samples: int, validation_fraction: float = 0.1, test_fraction: float = 0.2):
35
+ """Return train, validation, full-train, and test indexes in time order."""
36
+
37
+ test_len = int(test_fraction * n_samples)
38
+ val_len = int(validation_fraction * n_samples)
39
+ train_len = n_samples - val_len - test_len
40
+ if train_len <= 0:
41
+ raise ValueError("Not enough samples for the requested split.")
42
+ train = np.arange(train_len)
43
+ val = np.arange(train_len, train_len + val_len)
44
+ full_train = np.arange(train_len + val_len)
45
+ test = np.arange(train_len + val_len, n_samples)
46
+ return train, val, full_train, test
47
+
48
+
49
+ def load_ndbc_wave_file(path: str | Path, features: list[str] | None = None) -> pd.DataFrame:
50
+ """Load an NDBC wave-height text file and clean sentinel missing values."""
51
+
52
+ import pandas as pd
53
+
54
+ features = features or ["WDIR", "WSPD", "GST", "APD", "WVHT"]
55
+ frame = pd.read_csv(path, sep=r"\s+", compression="infer")
56
+ frame = frame[features].replace(["99.0", "99.00", 99.0, 99.00], np.nan)
57
+ return frame.ffill().bfill().astype(float)
edrft/metrics.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def root_mean_squared_error(y_true, y_pred) -> float:
7
+ truth = np.asarray(y_true, dtype=float).ravel()
8
+ pred = np.asarray(y_pred, dtype=float).ravel()
9
+ return float(np.sqrt(np.mean((truth - pred) ** 2)))
10
+
11
+
12
+ def mean_absolute_percentage_error(y_true, y_pred, epsilon: float = 1e-8) -> float:
13
+ truth = np.asarray(y_true, dtype=float).ravel()
14
+ pred = np.asarray(y_pred, dtype=float).ravel()
15
+ denom = np.maximum(np.abs(truth), epsilon)
16
+ return float(np.mean(np.abs((truth - pred) / denom)))
17
+
18
+
19
+ def mean_absolute_scaled_error(y_true, y_pred, history, seasonality: int = 1) -> float:
20
+ truth = np.asarray(y_true, dtype=float).ravel()
21
+ pred = np.asarray(y_pred, dtype=float).ravel()
22
+ hist = np.asarray(history, dtype=float).ravel()
23
+ scale = np.mean(np.abs(hist[seasonality:] - hist[:-seasonality]))
24
+ if scale == 0:
25
+ return float("inf")
26
+ return float(np.mean(np.abs(truth - pred)) / scale)
edrft/models.py ADDED
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, Literal
5
+
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+ Aggregation = Literal["median", "mean"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RFTLayerParams:
15
+ """Hyperparameters for one RFT hidden layer."""
16
+
17
+ n_hidden: int = 64
18
+ regularization: float = 1e-3
19
+ input_scale: float = 0.1
20
+ transformer_layers: int = 1
21
+ num_heads: int = 1
22
+ dropout: float = 0.0
23
+
24
+
25
+ def _as_2d(values: np.ndarray | Iterable[float], name: str) -> np.ndarray:
26
+ array = np.asarray(values, dtype=np.float32)
27
+ if array.ndim == 1:
28
+ array = array.reshape(-1, 1)
29
+ if array.ndim != 2:
30
+ raise ValueError(f"{name} must be a 1D or 2D array.")
31
+ if not np.all(np.isfinite(array)):
32
+ raise ValueError(f"{name} contains NaN or infinite values.")
33
+ return array
34
+
35
+
36
+ def _ridge_solve(design: np.ndarray, target: np.ndarray, regularization: float) -> np.ndarray:
37
+ penalty = float(regularization) * np.eye(design.shape[1], dtype=np.float64)
38
+ penalty[-1, -1] = 0.0
39
+ left = design.T @ design + penalty
40
+ right = design.T @ target
41
+ try:
42
+ return np.linalg.solve(left, right)
43
+ except np.linalg.LinAlgError:
44
+ return np.linalg.pinv(left) @ right
45
+
46
+
47
+ class _RandomTransformerBlock(nn.Module):
48
+ def __init__(
49
+ self,
50
+ n_features: int,
51
+ n_hidden: int,
52
+ input_scale: float,
53
+ transformer_layers: int,
54
+ num_heads: int,
55
+ dropout: float,
56
+ ) -> None:
57
+ super().__init__()
58
+ heads = int(num_heads) if n_features % int(num_heads) == 0 else 1
59
+ encoder_layer = nn.TransformerEncoderLayer(
60
+ d_model=n_features,
61
+ nhead=max(1, heads),
62
+ dim_feedforward=int(n_hidden),
63
+ dropout=float(dropout),
64
+ activation="relu",
65
+ batch_first=True,
66
+ )
67
+ self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=int(transformer_layers))
68
+ self.projection = nn.Linear(n_features, int(n_hidden))
69
+ self.activation = nn.Tanh()
70
+ self.input_scale = float(input_scale)
71
+ self.reset_parameters()
72
+
73
+ def reset_parameters(self) -> None:
74
+ for parameter in self.encoder.parameters():
75
+ if parameter.ndim > 1:
76
+ nn.init.uniform_(parameter, -self.input_scale, self.input_scale)
77
+ else:
78
+ nn.init.zeros_(parameter)
79
+ nn.init.uniform_(self.projection.weight, -self.input_scale, self.input_scale)
80
+ nn.init.uniform_(self.projection.bias, -self.input_scale, self.input_scale)
81
+
82
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
83
+ encoded = self.encoder(x.unsqueeze(1)).squeeze(1)
84
+ return self.activation(self.projection(encoded))
85
+
86
+
87
+ class RFTRegressor:
88
+ """Random Vector Functional Link Transformer regressor.
89
+
90
+ Hidden transformer weights are randomly initialized and fixed. Only the
91
+ output readout is solved with ridge regression.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ n_hidden: int = 64,
97
+ regularization: float = 1e-3,
98
+ input_scale: float = 0.1,
99
+ transformer_layers: int = 1,
100
+ num_heads: int = 1,
101
+ dropout: float = 0.0,
102
+ random_state: int | None = None,
103
+ device: str = "cpu",
104
+ ) -> None:
105
+ self.n_hidden = n_hidden
106
+ self.regularization = regularization
107
+ self.input_scale = input_scale
108
+ self.transformer_layers = transformer_layers
109
+ self.num_heads = num_heads
110
+ self.dropout = dropout
111
+ self.random_state = random_state
112
+ self.device = device
113
+
114
+ def fit(self, X: np.ndarray | Iterable[float], y: np.ndarray | Iterable[float]) -> "RFTRegressor":
115
+ X_arr = _as_2d(X, "X")
116
+ y_arr = _as_2d(y, "y").astype(np.float64)
117
+ if X_arr.shape[0] != y_arr.shape[0]:
118
+ raise ValueError("X and y must contain the same number of samples.")
119
+ torch.manual_seed(0 if self.random_state is None else int(self.random_state))
120
+ self.block_ = _RandomTransformerBlock(
121
+ X_arr.shape[1],
122
+ int(self.n_hidden),
123
+ float(self.input_scale),
124
+ int(self.transformer_layers),
125
+ int(self.num_heads),
126
+ float(self.dropout),
127
+ ).to(self.device)
128
+ self.block_.eval()
129
+ hidden = self._hidden(X_arr)
130
+ design = self._design(X_arr, hidden)
131
+ self.coef_ = _ridge_solve(design, y_arr, float(self.regularization))
132
+ self.n_features_in_ = X_arr.shape[1]
133
+ self.n_outputs_ = y_arr.shape[1]
134
+ return self
135
+
136
+ def predict(self, X: np.ndarray | Iterable[float]) -> np.ndarray:
137
+ self._check_fitted()
138
+ X_arr = _as_2d(X, "X")
139
+ if X_arr.shape[1] != self.n_features_in_:
140
+ raise ValueError(f"Expected {self.n_features_in_} features, got {X_arr.shape[1]}.")
141
+ pred = self._design(X_arr, self._hidden(X_arr)) @ self.coef_
142
+ return pred.ravel() if self.n_outputs_ == 1 else pred
143
+
144
+ def _hidden(self, X: np.ndarray) -> np.ndarray:
145
+ with torch.no_grad():
146
+ tensor = torch.as_tensor(X, dtype=torch.float32, device=self.device)
147
+ return self.block_(tensor).cpu().numpy().astype(np.float64)
148
+
149
+ @staticmethod
150
+ def _design(X: np.ndarray, hidden: np.ndarray) -> np.ndarray:
151
+ return np.hstack([hidden, X.astype(np.float64), np.ones((X.shape[0], 1))])
152
+
153
+ def _check_fitted(self) -> None:
154
+ if not hasattr(self, "coef_"):
155
+ raise RuntimeError("The model must be fitted before prediction.")
156
+
157
+
158
+ class EDRFTRegressor:
159
+ """Ensemble deep RFT regressor with one ridge readout per layer."""
160
+
161
+ def __init__(
162
+ self,
163
+ n_layers: int = 3,
164
+ n_hidden: int = 64,
165
+ regularization: float = 1e-3,
166
+ input_scale: float = 0.1,
167
+ transformer_layers: int = 1,
168
+ num_heads: int = 1,
169
+ dropout: float = 0.0,
170
+ aggregation: Aggregation = "median",
171
+ random_state: int | None = None,
172
+ device: str = "cpu",
173
+ layer_params: list[RFTLayerParams | dict] | None = None,
174
+ ) -> None:
175
+ self.n_layers = n_layers
176
+ self.n_hidden = n_hidden
177
+ self.regularization = regularization
178
+ self.input_scale = input_scale
179
+ self.transformer_layers = transformer_layers
180
+ self.num_heads = num_heads
181
+ self.dropout = dropout
182
+ self.aggregation = aggregation
183
+ self.random_state = random_state
184
+ self.device = device
185
+ self.layer_params = layer_params
186
+
187
+ def fit(self, X: np.ndarray | Iterable[float], y: np.ndarray | Iterable[float]) -> "EDRFTRegressor":
188
+ X_arr = _as_2d(X, "X")
189
+ y_arr = _as_2d(y, "y").astype(np.float64)
190
+ if X_arr.shape[0] != y_arr.shape[0]:
191
+ raise ValueError("X and y must contain the same number of samples.")
192
+
193
+ torch.manual_seed(0 if self.random_state is None else int(self.random_state))
194
+ state = X_arr
195
+ self.layers_ = []
196
+ for params in self._resolved_layer_params():
197
+ block = _RandomTransformerBlock(
198
+ state.shape[1],
199
+ params.n_hidden,
200
+ params.input_scale,
201
+ params.transformer_layers,
202
+ params.num_heads,
203
+ params.dropout,
204
+ ).to(self.device)
205
+ block.eval()
206
+ with torch.no_grad():
207
+ hidden = block(torch.as_tensor(state, dtype=torch.float32, device=self.device))
208
+ hidden_np = hidden.cpu().numpy().astype(np.float64)
209
+ design = np.hstack([hidden_np, X_arr.astype(np.float64), np.ones((X_arr.shape[0], 1))])
210
+ coef = _ridge_solve(design, y_arr, params.regularization)
211
+ self.layers_.append({"block": block, "coef": coef, "params": params})
212
+ state = hidden_np.astype(np.float32)
213
+
214
+ self.n_features_in_ = X_arr.shape[1]
215
+ self.n_outputs_ = y_arr.shape[1]
216
+ return self
217
+
218
+ def predict(self, X: np.ndarray | Iterable[float], return_layers: bool = False) -> np.ndarray:
219
+ self._check_fitted()
220
+ X_arr = _as_2d(X, "X")
221
+ if X_arr.shape[1] != self.n_features_in_:
222
+ raise ValueError(f"Expected {self.n_features_in_} features, got {X_arr.shape[1]}.")
223
+ state = X_arr
224
+ outputs = []
225
+ for layer in self.layers_:
226
+ with torch.no_grad():
227
+ hidden = layer["block"](torch.as_tensor(state, dtype=torch.float32, device=self.device))
228
+ hidden_np = hidden.cpu().numpy().astype(np.float64)
229
+ design = np.hstack([hidden_np, X_arr.astype(np.float64), np.ones((X_arr.shape[0], 1))])
230
+ outputs.append(design @ layer["coef"])
231
+ state = hidden_np.astype(np.float32)
232
+
233
+ stacked = np.stack(outputs, axis=0)
234
+ if return_layers:
235
+ result = np.moveaxis(stacked, 0, 1)
236
+ elif self.aggregation == "mean":
237
+ result = np.mean(stacked, axis=0)
238
+ elif self.aggregation == "median":
239
+ result = np.median(stacked, axis=0)
240
+ else:
241
+ raise ValueError("aggregation must be 'mean' or 'median'.")
242
+ return result.ravel() if self.n_outputs_ == 1 and result.ndim == 2 else result
243
+
244
+ def _resolved_layer_params(self) -> list[RFTLayerParams]:
245
+ if self.layer_params is None:
246
+ return [
247
+ RFTLayerParams(
248
+ self.n_hidden,
249
+ self.regularization,
250
+ self.input_scale,
251
+ self.transformer_layers,
252
+ self.num_heads,
253
+ self.dropout,
254
+ )
255
+ for _ in range(int(self.n_layers))
256
+ ]
257
+ params = []
258
+ for item in self.layer_params:
259
+ params.append(item if isinstance(item, RFTLayerParams) else RFTLayerParams(**item))
260
+ if not params:
261
+ raise ValueError("layer_params cannot be empty.")
262
+ return params
263
+
264
+ def _check_fitted(self) -> None:
265
+ if not hasattr(self, "layers_"):
266
+ raise RuntimeError("The model must be fitted before prediction.")
edrft/tuning.py ADDED
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable
5
+
6
+ import numpy as np
7
+ from hyperopt import STATUS_OK, Trials, fmin, hp, tpe
8
+
9
+ from .metrics import root_mean_squared_error
10
+ from .models import EDRFTRegressor, RFTLayerParams, RFTRegressor
11
+
12
+ Scorer = Callable[[np.ndarray, np.ndarray], float]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TuningResult:
17
+ model: Any
18
+ best_params: dict[str, Any]
19
+ best_score: float
20
+ history: list[dict[str, Any]]
21
+
22
+
23
+ def default_rft_space(seed: int = 0) -> dict[str, Any]:
24
+ """Hyperopt space matching the RFT/default edRFT search ranges."""
25
+
26
+ return {
27
+ "n_hidden": hp.quniform("n_hidden", 32, 1024, 32),
28
+ "regularization": hp.uniform("regularization", 0, 1),
29
+ "input_scale": hp.uniform("input_scale", 0, 1),
30
+ "transformer_layers": hp.choice("transformer_layers", [1, 2, 3, 4, 5]),
31
+ "num_heads": hp.choice("num_heads", [2, 4, 6, 8]),
32
+ "dropout": hp.uniform("dropout", 0, 0.5),
33
+ "random_state": seed,
34
+ }
35
+
36
+
37
+ def default_edrft_layer_space() -> dict[str, Any]:
38
+ return {
39
+ "n_hidden": hp.quniform("n_hidden", 32, 1024, 32),
40
+ "regularization": hp.uniform("regularization", 0, 1),
41
+ "input_scale": hp.uniform("input_scale", 0, 1),
42
+ "transformer_layers": hp.choice("transformer_layers", [1, 2, 3, 4, 5]),
43
+ "num_heads": hp.choice("num_heads", [2, 4, 6, 8]),
44
+ "dropout": hp.uniform("dropout", 0, 0.5),
45
+ }
46
+
47
+
48
+ def tune_rft(
49
+ X,
50
+ y,
51
+ space: dict[str, Any] | None = None,
52
+ scorer: Scorer = root_mean_squared_error,
53
+ validation_fraction: float = 0.2,
54
+ max_evals: int = 100,
55
+ random_state: int = 0,
56
+ refit: bool = True,
57
+ ) -> TuningResult:
58
+ return _tune(
59
+ RFTRegressor,
60
+ X,
61
+ y,
62
+ default_rft_space(random_state) if space is None else space,
63
+ scorer,
64
+ validation_fraction,
65
+ max_evals,
66
+ random_state,
67
+ refit,
68
+ )
69
+
70
+
71
+ def layerwise_tune_edrft(
72
+ X,
73
+ y,
74
+ n_layers: int = 10,
75
+ layer_space: dict[str, Any] | None = None,
76
+ scorer: Scorer = root_mean_squared_error,
77
+ validation_fraction: float = 0.2,
78
+ max_evals: int = 100,
79
+ random_state: int = 0,
80
+ refit: bool = True,
81
+ fixed_params: dict[str, Any] | None = None,
82
+ ) -> TuningResult:
83
+ if n_layers <= 0:
84
+ raise ValueError("n_layers must be positive.")
85
+ X_arr = np.asarray(X, dtype=np.float32)
86
+ y_arr = np.asarray(y, dtype=np.float32)
87
+ X_train, X_val, y_train, y_val = _split(X_arr, y_arr, validation_fraction)
88
+ selected: list[RFTLayerParams] = []
89
+ history = []
90
+ fixed_params = dict(fixed_params or {})
91
+ space = default_edrft_layer_space() if layer_space is None else layer_space
92
+
93
+ for layer_index in range(n_layers):
94
+ layer_history = []
95
+
96
+ def objective(params):
97
+ clean = _clean_params(params)
98
+ if layer_index > 0:
99
+ clean["n_hidden"] = selected[0].n_hidden
100
+ candidate = selected + [RFTLayerParams(**clean)]
101
+ model = EDRFTRegressor(layer_params=candidate, random_state=random_state, **fixed_params).fit(
102
+ X_train, y_train
103
+ )
104
+ score = float(scorer(y_val, model.predict(X_val)))
105
+ record = {"layer": layer_index + 1, "score": score, **clean}
106
+ layer_history.append(record)
107
+ history.append(record)
108
+ return {"loss": score, "status": STATUS_OK}
109
+
110
+ fmin(
111
+ objective,
112
+ space=space,
113
+ algo=tpe.suggest,
114
+ max_evals=max_evals,
115
+ trials=Trials(),
116
+ rstate=np.random.default_rng(random_state + layer_index),
117
+ show_progressbar=False,
118
+ )
119
+ best = min(layer_history, key=lambda item: item["score"])
120
+ selected.append(
121
+ RFTLayerParams(
122
+ n_hidden=int(best["n_hidden"]),
123
+ regularization=float(best["regularization"]),
124
+ input_scale=float(best["input_scale"]),
125
+ transformer_layers=int(best["transformer_layers"]),
126
+ num_heads=int(best["num_heads"]),
127
+ dropout=float(best["dropout"]),
128
+ )
129
+ )
130
+
131
+ final_X, final_y = (X_arr, y_arr) if refit else (X_train, y_train)
132
+ best_params = {"layer_params": [param.__dict__ for param in selected], **fixed_params}
133
+ return TuningResult(
134
+ model=EDRFTRegressor(layer_params=selected, random_state=random_state, **fixed_params).fit(final_X, final_y),
135
+ best_params=best_params,
136
+ best_score=min(item["score"] for item in history if item["layer"] == n_layers),
137
+ history=history,
138
+ )
139
+
140
+
141
+ def _tune(estimator_cls, X, y, space, scorer, validation_fraction, max_evals, random_state, refit):
142
+ X_arr = np.asarray(X, dtype=np.float32)
143
+ y_arr = np.asarray(y, dtype=np.float32)
144
+ X_train, X_val, y_train, y_val = _split(X_arr, y_arr, validation_fraction)
145
+ history = []
146
+
147
+ def objective(params):
148
+ clean = _clean_params(params)
149
+ model = estimator_cls(**clean).fit(X_train, y_train)
150
+ score = float(scorer(y_val, model.predict(X_val)))
151
+ history.append({"score": score, **clean})
152
+ return {"loss": score, "status": STATUS_OK}
153
+
154
+ fmin(
155
+ objective,
156
+ space=space,
157
+ algo=tpe.suggest,
158
+ max_evals=max_evals,
159
+ trials=Trials(),
160
+ rstate=np.random.default_rng(random_state),
161
+ show_progressbar=False,
162
+ )
163
+ best = min(history, key=lambda item: item["score"])
164
+ best_params = {key: value for key, value in best.items() if key != "score"}
165
+ final_X, final_y = (X_arr, y_arr) if refit else (X_train, y_train)
166
+ return TuningResult(
167
+ model=estimator_cls(**best_params).fit(final_X, final_y),
168
+ best_params=best_params,
169
+ best_score=float(best["score"]),
170
+ history=history,
171
+ )
172
+
173
+
174
+ def _split(X, y, validation_fraction):
175
+ if not 0 < validation_fraction < 1:
176
+ raise ValueError("validation_fraction must be between 0 and 1.")
177
+ n_val = max(1, int(round(len(X) * validation_fraction)))
178
+ return X[:-n_val], X[-n_val:], y[:-n_val], y[-n_val:]
179
+
180
+
181
+ def _clean_params(params):
182
+ clean = dict(params)
183
+ for key in ("n_hidden", "transformer_layers", "num_heads", "random_state"):
184
+ if key in clean:
185
+ clean[key] = int(clean[key])
186
+ if "n_hidden" in clean:
187
+ clean["n_hidden"] = max(1, clean["n_hidden"])
188
+ for key in ("regularization", "input_scale", "dropout"):
189
+ if key in clean:
190
+ clean[key] = float(clean[key])
191
+ return clean
edrft/wave.py ADDED
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from time import perf_counter
6
+ from typing import Iterable
7
+
8
+ import numpy as np
9
+
10
+ from .data import chronological_split, load_ndbc_wave_file
11
+ from .metrics import mean_absolute_percentage_error, mean_absolute_scaled_error, root_mean_squared_error
12
+ from .models import EDRFTRegressor, RFTRegressor
13
+ from .tuning import layerwise_tune_edrft, tune_rft
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class WaveRunResult:
18
+ station: str
19
+ year: str
20
+ seed: int
21
+ model: str
22
+ rmse: float
23
+ mape: float
24
+ mase: float
25
+ tuning_seconds: float
26
+ training_seconds: float
27
+ testing_seconds: float
28
+ best_params: dict
29
+
30
+
31
+ def prepare_wave_supervised(
32
+ path: str | Path,
33
+ look_back: int = 48,
34
+ horizon: int = 4,
35
+ features: list[str] | None = None,
36
+ order: int | None = None,
37
+ ):
38
+ """Prepare NDBC wave data using wave forecasting lag and horizon settings."""
39
+
40
+ if order is not None:
41
+ look_back = order
42
+ if look_back <= 0 or horizon <= 0:
43
+ raise ValueError("look_back and horizon must be positive.")
44
+ frame = load_ndbc_wave_file(path, features=features)
45
+ values = frame.to_numpy(dtype=float)[1:]
46
+ target = frame["WVHT"].to_numpy(dtype=float)[1:]
47
+ n_samples = values.shape[0] - look_back - horizon + 1
48
+ if n_samples <= 0:
49
+ raise ValueError("series is too short for the requested look_back and horizon.")
50
+ X = np.zeros((n_samples, values.shape[1] * look_back), dtype=np.float32)
51
+ y = np.zeros(n_samples, dtype=np.float32)
52
+ for i in range(n_samples):
53
+ X[i] = values[i : i + look_back].ravel()
54
+ y[i] = target[i + look_back + horizon - 1]
55
+ return X, y
56
+
57
+
58
+ def run_wave_experiment(
59
+ data_dir: str | Path = "wave",
60
+ stations: Iterable[str] = ("46001h",),
61
+ years: Iterable[str] = ("2017",),
62
+ seeds: Iterable[int] = (0,),
63
+ look_back: int = 48,
64
+ horizon: int = 4,
65
+ n_layers: int = 10,
66
+ max_evals: int = 100,
67
+ order: int | None = None,
68
+ ) -> list[WaveRunResult]:
69
+ """Run RFT and edRFT on NDBC files without writing result files."""
70
+
71
+ if order is not None:
72
+ look_back = order
73
+ data_dir = Path(data_dir)
74
+ results: list[WaveRunResult] = []
75
+ for year in years:
76
+ for station in stations:
77
+ X, y = prepare_wave_supervised(
78
+ data_dir / f"{station}{year}.txt.gz",
79
+ look_back=look_back,
80
+ horizon=horizon,
81
+ )
82
+ train_idx, val_idx, full_train_idx, test_idx = chronological_split(len(X), 0.1, 0.2)
83
+ scaled = _scaled_splits(X, y, train_idx, val_idx, full_train_idx, test_idx)
84
+ for seed in seeds:
85
+ results.append(
86
+ _run_rft(
87
+ station,
88
+ year,
89
+ seed,
90
+ scaled,
91
+ max_evals=max_evals,
92
+ )
93
+ )
94
+ results.append(
95
+ _run_edrft(
96
+ station,
97
+ year,
98
+ seed,
99
+ scaled,
100
+ n_layers=n_layers,
101
+ max_evals=max_evals,
102
+ )
103
+ )
104
+ return results
105
+
106
+
107
+ def _run_rft(station, year, seed, scaled, max_evals):
108
+ train, val, full_train, test, inverse_y, history = scaled
109
+ tune_X = np.vstack([train[0], val[0]])
110
+ tune_y = np.concatenate([train[1], val[1]])
111
+
112
+ start = perf_counter()
113
+ tuned = tune_rft(
114
+ tune_X,
115
+ tune_y,
116
+ validation_fraction=len(val[1]) / len(tune_y),
117
+ max_evals=max_evals,
118
+ random_state=seed,
119
+ refit=False,
120
+ )
121
+ tuning_seconds = perf_counter() - start
122
+
123
+ start = perf_counter()
124
+ model = RFTRegressor(**tuned.best_params).fit(*full_train)
125
+ training_seconds = perf_counter() - start
126
+
127
+ start = perf_counter()
128
+ pred = model.predict(test[0])
129
+ testing_seconds = perf_counter() - start
130
+ return _result(station, year, seed, "RFT", test[1], pred, inverse_y, history, tuning_seconds, training_seconds, testing_seconds, tuned.best_params)
131
+
132
+
133
+ def _run_edrft(station, year, seed, scaled, n_layers, max_evals):
134
+ train, val, full_train, test, inverse_y, history = scaled
135
+ tune_X = np.vstack([train[0], val[0]])
136
+ tune_y = np.concatenate([train[1], val[1]])
137
+
138
+ start = perf_counter()
139
+ tuned = layerwise_tune_edrft(
140
+ tune_X,
141
+ tune_y,
142
+ n_layers=n_layers,
143
+ validation_fraction=len(val[1]) / len(tune_y),
144
+ max_evals=max_evals,
145
+ random_state=seed,
146
+ refit=False,
147
+ )
148
+ tuning_seconds = perf_counter() - start
149
+
150
+ start = perf_counter()
151
+ model = EDRFTRegressor(layer_params=tuned.best_params["layer_params"], random_state=seed).fit(*full_train)
152
+ training_seconds = perf_counter() - start
153
+
154
+ start = perf_counter()
155
+ pred = model.predict(test[0])
156
+ testing_seconds = perf_counter() - start
157
+ return _result(station, year, seed, "edRFT", test[1], pred, inverse_y, history, tuning_seconds, training_seconds, testing_seconds, tuned.best_params)
158
+
159
+
160
+ def _scaled_splits(X, y, train_idx, val_idx, full_train_idx, test_idx):
161
+ x_min = X[train_idx].min(axis=0)
162
+ x_range = np.maximum(X[train_idx].max(axis=0) - x_min, 1e-12)
163
+ y_min = y[train_idx].min()
164
+ y_range = max(float(y[train_idx].max() - y_min), 1e-12)
165
+
166
+ def scale_x(values):
167
+ return 2 * ((values - x_min) / x_range) - 1
168
+
169
+ def scale_y(values):
170
+ return 2 * ((values - y_min) / y_range) - 1
171
+
172
+ def inverse_y(values):
173
+ return ((np.asarray(values, dtype=float) + 1) / 2) * y_range + y_min
174
+
175
+ return (
176
+ (scale_x(X[train_idx]), scale_y(y[train_idx])),
177
+ (scale_x(X[val_idx]), scale_y(y[val_idx])),
178
+ (scale_x(X[full_train_idx]), scale_y(y[full_train_idx])),
179
+ (scale_x(X[test_idx]), scale_y(y[test_idx])),
180
+ inverse_y,
181
+ y[train_idx],
182
+ )
183
+
184
+
185
+ def _result(station, year, seed, model, y_true, y_pred, inverse_y, history, tuning, training, testing, best_params):
186
+ truth = inverse_y(y_true)
187
+ pred = inverse_y(y_pred)
188
+ return WaveRunResult(
189
+ station=station,
190
+ year=year,
191
+ seed=seed,
192
+ model=model,
193
+ rmse=root_mean_squared_error(truth, pred),
194
+ mape=mean_absolute_percentage_error(truth, pred),
195
+ mase=mean_absolute_scaled_error(truth, pred, history),
196
+ tuning_seconds=tuning,
197
+ training_seconds=training,
198
+ testing_seconds=testing,
199
+ best_params=best_params,
200
+ )
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: edrft
3
+ Version: 0.1.0
4
+ Summary: RFT and edRFT models for significant wave-height time-series forecasting.
5
+ Author: Aryan Bhambu
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/statsdl/edRFT
8
+ Project-URL: Issues, https://github.com/statsdl/edRFT/issues
9
+ Keywords: edrft,rft,rvfl,transformer,wave-height,forecasting
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.21
22
+ Requires-Dist: torch>=2.0
23
+ Provides-Extra: tuning
24
+ Requires-Dist: hyperopt>=0.2.7; extra == "tuning"
25
+ Requires-Dist: setuptools<81; extra == "tuning"
26
+ Provides-Extra: wave
27
+ Requires-Dist: hyperopt>=0.2.7; extra == "wave"
28
+ Requires-Dist: pandas>=1.3; extra == "wave"
29
+ Requires-Dist: setuptools<81; extra == "wave"
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == "dev"
32
+ Requires-Dist: hyperopt>=0.2.7; extra == "dev"
33
+ Requires-Dist: pandas>=1.3; extra == "dev"
34
+ Requires-Dist: pytest; extra == "dev"
35
+ Requires-Dist: setuptools<81; extra == "dev"
36
+ Requires-Dist: twine; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # edRFT
40
+
41
+ `edrft` provides Random Vector Functional Link Transformer models for
42
+ significant wave-height forecasting:
43
+
44
+ - `RFTRegressor`: a shallow randomized transformer encoder with ridge readout.
45
+ - `EDRFTRegressor`: an ensemble deep RFT with one output layer per hidden layer.
46
+ - Hyperopt/TPE tuning using the default edRFT search ranges.
47
+ - NDBC wave forecasting experiment helpers that do not write result artifacts.
48
+
49
+ The public package uses the model naming: `RFT` and `edRFT`. Older `rft` and
50
+ `edrft` script names are retained only under legacy files for traceability.
51
+
52
+ ## Installation
53
+
54
+ Core install:
55
+
56
+ ```bash
57
+ git clone https://github.com/statsdl/edRFT.git
58
+ cd edRFT
59
+ pip install .
60
+ ```
61
+
62
+ Wave experiment dependencies:
63
+
64
+ ```bash
65
+ pip install ".[wave]"
66
+ ```
67
+
68
+ Development:
69
+
70
+ ```bash
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
74
+
75
+ ## Quick Start
76
+
77
+ ```python
78
+ import numpy as np
79
+ from edrft import EDRFTRegressor, make_forecasting_frame
80
+
81
+ series = np.sin(np.linspace(0, 16, 240))
82
+ X, y = make_forecasting_frame(series, order=4)
83
+
84
+ model = EDRFTRegressor(n_layers=3, n_hidden=32, random_state=0)
85
+ model.fit(X[:180], y[:180])
86
+ pred = model.predict(X[180:])
87
+ ```
88
+
89
+ ## Wave Forecasting Example
90
+
91
+ ```bash
92
+ python examples/run_wave_forecasting.py \
93
+ --data-dir wave \
94
+ --stations 46001h \
95
+ --years 2017 \
96
+ --seeds 0 \
97
+ --look-back 48 \
98
+ --horizon 4 \
99
+ --layers 10 \
100
+ --max-evals 100
101
+ ```
102
+
103
+ The runner prints metrics to stdout only. It follows the original scripts:
104
+
105
+ - NDBC features: `WDIR`, `WSPD`, `GST`, `APD`, `WVHT`
106
+ - Missing sentinel cleanup
107
+ - Default look-back window: 48
108
+ - Default forecasting horizon: 4
109
+ - Min-max scaling to `[-1, 1]`
110
+ - Chronological split: 70% train, 10% validation, 20% test
111
+ - Hyperopt/TPE tuning with 100 evaluations by default
112
+ - Train+validation final fit
113
+ - RMSE, MAPE, MASE, and timing output
114
+
115
+ ## Hyperopt Tuning
116
+
117
+ ```python
118
+ from edrft.tuning import layerwise_tune_edrft
119
+
120
+ result = layerwise_tune_edrft(
121
+ X,
122
+ y,
123
+ n_layers=10,
124
+ validation_fraction=0.1 / 0.8,
125
+ max_evals=100,
126
+ random_state=0,
127
+ )
128
+ ```
129
+
130
+ ## Repository Notes
131
+
132
+ Supported package code lives in `src/edrft`.
133
+
134
+ The `legacy/`, `DeepRVFL_/`, `ForecastLib.py`, and old experiment scripts are
135
+ retained for traceability. They are not included in the PyPI wheel and are not
136
+ the supported package API.
137
+
138
+ ## PyPI Release
139
+
140
+ The publish workflow uses PyPI Trusted Publishing. Configure the PyPI trusted
141
+ publisher with:
142
+
143
+ - owner: `statsdl`
144
+ - repository: `edRFT`
145
+ - workflow: `publish.yml`
146
+ - environment: `pypi`
147
+
148
+ ## License
149
+
150
+ MIT
151
+
152
+ ## Reference
153
+
154
+ If you use edRFT in your work, please cite:
155
+
156
+ ```bibtex
157
+ @article{bhambu2025deep,
158
+ title={Deep random vector functional link transformer network with multiple output layers for significant wave height forecasting},
159
+ author={Bhambu, Aryan and Gao, Ruobin and Suganthan, Ponnuthurai Nagaratnam and Selvaraju, Natarajan},
160
+ journal={Applied Soft Computing},
161
+ pages={114136},
162
+ year={2025},
163
+ publisher={Elsevier}
164
+ }
165
+ ```
166
+
@@ -0,0 +1,11 @@
1
+ edrft/__init__.py,sha256=ZtIFqVd4Xk-kpKKGPNHZHoKokpBVIPtrRY8QIlOkUfg,551
2
+ edrft/data.py,sha256=LkHp-BJR_KSKIOVkUXY6jPWfYQAKZUQ4ZizwjniKcCI,2215
3
+ edrft/metrics.py,sha256=6q1yofHAhwD5rICVBdq5Ncqucctwo1lBwOH78o5o7rQ,982
4
+ edrft/models.py,sha256=GnLiTQ1A82HwVz-Na7kF8bQVRyauWnAswk12NEF_NUI,10062
5
+ edrft/tuning.py,sha256=vLq46ciebAfNv57FMHBenvySgD2CVBlRdHQFcMCHU7Y,6544
6
+ edrft/wave.py,sha256=O0u0pLTNUqkctfTxel96RJsD5j7qk5T32lhGmKQIW_0,6544
7
+ edrft-0.1.0.dist-info/licenses/LICENSE,sha256=o-oucML3-pP9uO0PIFnQXUcXY8i8DiyRZieAsyPJLfY,1069
8
+ edrft-0.1.0.dist-info/METADATA,sha256=p5VaSadudLh9HL1FqMeGyNyQ-ZtFw16B7TQi6Pk68-M,4389
9
+ edrft-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ edrft-0.1.0.dist-info/top_level.txt,sha256=zKAm5kZBsSXevNfPi2mpMTt9ROQo5HI2ZbBjhAm8RhE,6
11
+ edrft-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aryan Bhambu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ edrft