skuld-lib 0.4.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.
skuld/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ ##########################################################################
2
+ ########################### SKULD NNI LIBRARY ############################
3
+ ##########################################################################
4
+ ### version 0.2.1 ###
5
+ ### a neural network based ###
6
+ ### numerical integration library ###
7
+ ##########################################################################
8
+
9
+ __version__ = "0.2.1"
10
+
11
+ from .model import (
12
+ MLP,
13
+ init_model,
14
+ split_data,
15
+ set_global_device
16
+ )
17
+
18
+ from .scalers import (
19
+ scale_data,
20
+ descale_result
21
+ )
22
+
23
+ from .nni import (
24
+ NeuralNumericalIntegration
25
+ )
26
+
27
+ from .generators import (
28
+ generate_data,
29
+ )
30
+
31
+ from .siren import (
32
+ SirenLayer,
33
+ SirenPrimitiveNet,
34
+ SirenIntegrator,
35
+ mixed_partial_3,
36
+ )
37
+
38
+ from .wire import (
39
+ WireLayer,
40
+ WireResidualBlock,
41
+ WirePrimitiveNet,
42
+ WireIntegrator,
43
+ )
44
+
45
+ from .kan import (
46
+ BSplineBasis,
47
+ KANLayer,
48
+ KANPrimitiveNet,
49
+ KANIntegrator,
50
+ )
51
+
52
+ from .broknet import (
53
+ SindriNet,
54
+ BrokNet,
55
+ BrokNetIntegrator,
56
+ mixed_partial_3_expert,
57
+ )
58
+
59
+ __all__ = [
60
+ "MLP",
61
+ "NeuralNumericalIntegration",
62
+ "init_model",
63
+ "split_data",
64
+ "scale_data",
65
+ "descale_result",
66
+ "generate_data",
67
+ "set_global_device",
68
+ "SirenLayer",
69
+ "SirenPrimitiveNet",
70
+ "SirenIntegrator",
71
+ "mixed_partial_3",
72
+ "WireLayer",
73
+ "WireResidualBlock",
74
+ "WirePrimitiveNet",
75
+ "WireIntegrator",
76
+ "BSplineBasis",
77
+ "KANLayer",
78
+ "KANPrimitiveNet",
79
+ "KANIntegrator",
80
+ "SindriNet",
81
+ "BrokNet",
82
+ "BrokNetIntegrator",
83
+ "mixed_partial_3_expert",
84
+ ]
skuld/broknet.py ADDED
@@ -0,0 +1,295 @@
1
+ ##############################################################################
2
+ # BrokNET: Mixture-of-Experts Neural Numerical Integration
3
+ #
4
+ # BrokNet is an ensemble of expert subnetworks (SindriNet), each trained
5
+ # on a single parameter set. A deterministic router dispatches inputs
6
+ # to the correct expert based on the discrete parameter label.
7
+ #
8
+ # Named after Brok and Sindri, the Huldra dwarves from God of War.
9
+ ##############################################################################
10
+ import itertools
11
+ import math
12
+ import time
13
+ from typing import Callable, Dict, List, Tuple
14
+
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+ from .siren import SirenLayer
20
+
21
+
22
+ ##############################################################################
23
+ # SindriNet — Expert Subnetwork
24
+ ##############################################################################
25
+
26
+ class SindriNet(nn.Module):
27
+ """Expert subnetwork S_i(u1, u2, u3) — SIREN-based antiderivative approximator
28
+ for a single fixed parameter set (a_i, b_i, m_i, n_i).
29
+
30
+ Input: integration variables only (n_int_vars = 3 dimensions)
31
+ Output: scalar F_i(u) such that d^3 F_i / du1 du2 du3 ≈ f_tilde_i(u)
32
+ """
33
+
34
+ def __init__(self,
35
+ n_int_vars: int = 3,
36
+ hidden_sizes: List[int] = None,
37
+ omega_0: float = 30.0,
38
+ output_scale: float = 1.0):
39
+ super().__init__()
40
+ if hidden_sizes is None:
41
+ hidden_sizes = [64, 64, 64]
42
+
43
+ self.n_int_vars = n_int_vars
44
+ self.omega_0 = omega_0
45
+
46
+ layers = []
47
+ in_dim = n_int_vars
48
+ for i, h in enumerate(hidden_sizes):
49
+ layers.append(SirenLayer(in_dim, h,
50
+ omega_0=omega_0,
51
+ is_first=(i == 0)))
52
+ in_dim = h
53
+
54
+ final = nn.Linear(in_dim, 1)
55
+ with torch.no_grad():
56
+ bound = math.sqrt(6.0 / in_dim) / omega_0
57
+ final.weight.uniform_(-bound, bound)
58
+ final.bias.uniform_(-bound, bound)
59
+ final.weight.data *= output_scale
60
+ final.bias.data *= output_scale
61
+ layers.append(final)
62
+
63
+ self.net = nn.Sequential(*layers)
64
+
65
+ def forward(self, u: torch.Tensor) -> torch.Tensor:
66
+ return self.net(u)
67
+
68
+
69
+ ##############################################################################
70
+ # BrokNet — Mixture-of-Experts Wrapper
71
+ ##############################################################################
72
+
73
+ class BrokNet(nn.Module):
74
+ """Brok — Mixture-of-Experts SIREN for parametric numerical integration.
75
+
76
+ Accepts full input (a, b, m, n, u1, u2, u3), routes by the discrete key
77
+ (a, b, m, n) to the corresponding SindriNet expert, which then operates
78
+ only on (u1, u2, u3).
79
+
80
+ The router is a plain dict — no learnable gating, no softmax.
81
+ """
82
+
83
+ def __init__(self,
84
+ param_sets: List[Tuple],
85
+ n_int_vars: int = 3,
86
+ hidden_sizes: List[int] = None,
87
+ omega_0: float = 30.0,
88
+ output_scale: float = 1.0):
89
+ super().__init__()
90
+ if hidden_sizes is None:
91
+ hidden_sizes = [64, 64, 64]
92
+
93
+ self.param_sets = param_sets
94
+ self.n_int_vars = n_int_vars
95
+ self.n_params = 4
96
+
97
+ self.experts = nn.ModuleList([
98
+ SindriNet(n_int_vars=n_int_vars,
99
+ hidden_sizes=hidden_sizes,
100
+ omega_0=omega_0,
101
+ output_scale=output_scale)
102
+ for _ in param_sets
103
+ ])
104
+
105
+ self.router: Dict[Tuple, int] = {
106
+ tuple(float(v) for v in ps): i
107
+ for i, ps in enumerate(param_sets)
108
+ }
109
+
110
+ def expert_index(self, a: float, b: float, m: float, n: float) -> int:
111
+ key = (float(a), float(b), float(m), float(n))
112
+ if key not in self.router:
113
+ raise KeyError(
114
+ f"Parameter set {key} was not registered at construction time. "
115
+ f"Available sets: {list(self.router.keys())}"
116
+ )
117
+ return self.router[key]
118
+
119
+ def forward_expert(self, expert_idx: int, u: torch.Tensor) -> torch.Tensor:
120
+ return self.experts[expert_idx](u)
121
+
122
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
123
+ key = tuple(float(v) for v in x[0, :4].tolist())
124
+ idx = self.router[key]
125
+ u = x[:, 4:]
126
+ return self.experts[idx](u)
127
+
128
+
129
+ ##############################################################################
130
+ # Mixed Third Partial Derivative via Autograd
131
+ ##############################################################################
132
+
133
+ def mixed_partial_3_expert(expert: SindriNet,
134
+ u: torch.Tensor) -> torch.Tensor:
135
+ """Compute d^3 S_i / du1 du2 du3 for a single SindriNet expert."""
136
+ u_d = u.detach().requires_grad_(True)
137
+ G_out = expert(u_d).squeeze(-1)
138
+ ones = torch.ones_like(G_out)
139
+
140
+ g1 = torch.autograd.grad(
141
+ G_out, u_d, grad_outputs=ones,
142
+ create_graph=True, retain_graph=True,
143
+ )[0][:, 0]
144
+
145
+ g12 = torch.autograd.grad(
146
+ g1, u_d, grad_outputs=torch.ones_like(g1),
147
+ create_graph=True, retain_graph=True,
148
+ )[0][:, 1]
149
+
150
+ g123 = torch.autograd.grad(
151
+ g12, u_d, grad_outputs=torch.ones_like(g12),
152
+ create_graph=True,
153
+ )[0][:, 2]
154
+
155
+ return g123
156
+
157
+
158
+ ##############################################################################
159
+ # BrokNetIntegrator: train + evaluate
160
+ ##############################################################################
161
+
162
+ class BrokNetIntegrator:
163
+ """Application of BrokNet for numerical integration (Maitre et al. approach).
164
+
165
+ Trains a BrokNet (mixture of SindriNet experts) to approximate the
166
+ antiderivative for each parameter set, then evaluates the integral
167
+ via an alternating-sign corner sum over the unit hypercube.
168
+ """
169
+
170
+ def __init__(self,
171
+ param_sets: List[Tuple],
172
+ n_int_vars: int = 3,
173
+ hidden_sizes: List[int] = None,
174
+ omega_0: float = 30.0,
175
+ output_scale: float = 1.0):
176
+ self.param_sets = param_sets
177
+ self.n_int_vars = n_int_vars
178
+ self.n_params = 4
179
+ self.net = BrokNet(
180
+ param_sets=param_sets,
181
+ n_int_vars=n_int_vars,
182
+ hidden_sizes=hidden_sizes,
183
+ omega_0=omega_0,
184
+ output_scale=output_scale,
185
+ )
186
+
187
+ @property
188
+ def n_weights(self) -> int:
189
+ return sum(p.numel() for p in self.net.parameters())
190
+
191
+ @property
192
+ def n_experts(self) -> int:
193
+ return len(self.param_sets)
194
+
195
+ def _center_value(self, integrand_fn, a, b, m, n) -> float:
196
+ uc = torch.full((1, self.n_int_vars), 0.5)
197
+ fc = integrand_fn(uc, a, b, m, n).item()
198
+ return fc if abs(fc) > 1e-30 else 1.0
199
+
200
+ def _make_expert_batch(self, integrand_fn, a, b, m, n, n_samples, device, norm_cache):
201
+ key = (float(a), float(b), float(m), float(n))
202
+ if key not in norm_cache:
203
+ norm_cache[key] = self._center_value(integrand_fn, a, b, m, n)
204
+ fc = norm_cache[key]
205
+
206
+ u = torch.rand(n_samples, self.n_int_vars)
207
+ f = integrand_fn(u, a, b, m, n) / fc
208
+
209
+ return u.to(device), f.to(device)
210
+
211
+ def train(self,
212
+ integrand_fn: Callable,
213
+ n_epochs: int = 8000,
214
+ n_per_expert: int = 1024,
215
+ lr: float = 5e-4,
216
+ device: torch.device = None,
217
+ verbose_every: int = 500) -> tuple:
218
+ """Train BrokNet experts.
219
+
220
+ Returns: (loss_history, norm_cache)
221
+ """
222
+ if device is None:
223
+ device = torch.device("cpu")
224
+
225
+ optimizer = torch.optim.Adam(self.net.parameters(), lr=lr)
226
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
227
+ optimizer, T_max=n_epochs, eta_min=lr / 10
228
+ )
229
+ loss_fn = nn.MSELoss()
230
+ self.net.to(device)
231
+ self.net.train()
232
+
233
+ n_experts = len(self.param_sets)
234
+ history = []
235
+ norm_cache: Dict = {}
236
+ t0 = time.time()
237
+
238
+ for epoch in range(1, n_epochs + 1):
239
+ optimizer.zero_grad()
240
+ expert_losses_tensors = []
241
+
242
+ for i, (a, b, m, n) in enumerate(self.param_sets):
243
+ u, f_tilde = self._make_expert_batch(
244
+ integrand_fn, a, b, m, n, n_per_expert, device, norm_cache
245
+ )
246
+ dG = mixed_partial_3_expert(self.net.experts[i], u)
247
+ loss_i = loss_fn(dG, f_tilde)
248
+ expert_losses_tensors.append(loss_i)
249
+
250
+ total_loss = torch.stack(expert_losses_tensors).mean()
251
+ total_loss.backward()
252
+ torch.nn.utils.clip_grad_norm_(self.net.parameters(), max_norm=5.0)
253
+ optimizer.step()
254
+ scheduler.step()
255
+
256
+ lv = total_loss.item()
257
+ history.append(lv)
258
+
259
+ if verbose_every > 0 and (epoch % verbose_every == 0 or epoch == 1):
260
+ lr_now = scheduler.get_last_lr()[0]
261
+ elapsed = time.time() - t0
262
+ print(f" Epoch {epoch:5d}/{n_epochs} "
263
+ f"loss={lv:.4e} lr={lr_now:.2e} "
264
+ f"({elapsed:.1f}s)")
265
+
266
+ return history, norm_cache
267
+
268
+ def integrate(self,
269
+ a: float, b: float, m: float, n: float,
270
+ norm_cache: dict = None,
271
+ device: torch.device = None) -> float:
272
+ """Evaluate the integral for a given parameter set via corner sum."""
273
+ if device is None:
274
+ device = torch.device("cpu")
275
+
276
+ self.net.eval()
277
+ self.net.to(device)
278
+
279
+ key = (float(a), float(b), float(m), float(n))
280
+ if norm_cache and key in norm_cache:
281
+ fc = norm_cache[key]
282
+ else:
283
+ fc = 1.0
284
+
285
+ expert_idx = self.net.expert_index(a, b, m, n)
286
+ expert = self.net.experts[expert_idx]
287
+
288
+ I_tilde = 0.0
289
+ with torch.no_grad():
290
+ for corner in itertools.product([0.0, 1.0], repeat=self.n_int_vars):
291
+ sign = (-1) ** (self.n_int_vars - sum(corner))
292
+ u_t = torch.tensor([corner], dtype=torch.float32).to(device)
293
+ I_tilde += sign * expert(u_t).item()
294
+
295
+ return float(I_tilde * fc)