korallll commited on
Commit
f2beec2
·
verified ·
1 Parent(s): c8057ae

Add model code (_net.py, evaluate.py, res2tcnguard.py); fix README usage; precise params

Browse files
Files changed (4) hide show
  1. README.md +31 -13
  2. _net.py +326 -0
  3. evaluate.py +68 -0
  4. res2tcnguard.py +43 -0
README.md CHANGED
@@ -21,9 +21,14 @@ score where **higher = more bona fide**.
21
 
22
  - **Code:** https://github.com/lab260ru/Res2TCNGuard
23
  - **Paper:** https://etasr.com/index.php/ETASR/article/view/8906 (DOI: 10.48084/etasr.8906)
24
- - **Parameters:** ~0.17 M
25
  - **Checkpoint:** [`best_1.495.pth`](./best_1.495.pth)
26
 
 
 
 
 
 
27
  ## Architecture
28
 
29
  Res2TCNGuard operates directly on the raw waveform:
@@ -67,23 +72,36 @@ This reproduces the paper's reported 1.49 % on the ASVspoof 2019 LA eval set.
67
 
68
  ## Usage
69
 
70
- This checkpoint is a `state_dict` for the `TestModel` network defined in the
71
- [source repository](https://github.com/lab260ru/Res2TCNGuard). Load the architecture
72
- from there, then:
 
 
73
 
74
- ```python
75
- import torch
76
- from TCN import TestModel # network definition from the source repo
 
 
 
 
77
 
78
- model = TestModel()
79
- model.load_state_dict(torch.load("best_1.495.pth", map_location="cpu"))
80
- model.eval()
81
 
82
- # x: float32 waveform, 16 kHz mono, shape (batch, 64600)
83
- _, logits = model(x)
84
- bonafide_score = logits[:, 1] # higher = more bona fide
 
 
 
 
85
  ```
86
 
 
 
 
 
 
87
  ## Citation
88
 
89
  **This model / paper:**
 
21
 
22
  - **Code:** https://github.com/lab260ru/Res2TCNGuard
23
  - **Paper:** https://etasr.com/index.php/ETASR/article/view/8906 (DOI: 10.48084/etasr.8906)
24
+ - **Parameters:** 172,102 (0.172 M)
25
  - **Checkpoint:** [`best_1.495.pth`](./best_1.495.pth)
26
 
27
+ This repo is self-contained for inference: the network definition is in
28
+ [`_net.py`](./_net.py), a standalone scorer in [`evaluate.py`](./evaluate.py), and
29
+ the exact wrapper used to produce the Arena scores in
30
+ [`res2tcnguard.py`](./res2tcnguard.py).
31
+
32
  ## Architecture
33
 
34
  Res2TCNGuard operates directly on the raw waveform:
 
72
 
73
  ## Usage
74
 
75
+ The checkpoint is a `state_dict` for the `TestModel` network defined in
76
+ [`_net.py`](./_net.py) (extracted verbatim from the source notebook). The input
77
+ **must** be exactly 64,600 samples at 16 kHz mono — the classifier head is
78
+ fixed-size — so window the waveform with `pad_fixed` (first 64,600 samples,
79
+ tile-repeat if shorter).
80
 
81
+ Score one file from the command line:
82
+
83
+ ```bash
84
+ pip install torch numpy soundfile scipy
85
+ python evaluate.py path/to/audio.wav
86
+ # -> bona-fide score: <float> (higher = more bona fide)
87
+ ```
88
 
89
+ Or from Python:
 
 
90
 
91
+ ```python
92
+ import numpy as np
93
+ from evaluate import load_model, score # _net.py + evaluate.py are in this repo
94
+
95
+ model = load_model("best_1.495.pth", device="cpu")
96
+ audio = np.random.randn(48000).astype(np.float32) # float32 mono 16 kHz
97
+ print(score(model, audio)) # higher = more bona fide
98
  ```
99
 
100
+ Internally `score` does `_, logits = model(x)` on the windowed input and returns
101
+ `logits[:, 1]` (class 1 = bona fide). [`res2tcnguard.py`](./res2tcnguard.py) is the
102
+ same logic packaged as a `speech_spoof_bench` model — the exact code that produced
103
+ the Arena `scores.txt`.
104
+
105
  ## Citation
106
 
107
  **This model / paper:**
_net.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numpy as np
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+ from torch.nn.utils import weight_norm
7
+
8
+ class SincConv_fast(nn.Module):
9
+ @staticmethod
10
+ def to_mel(hz):
11
+ return 2595 * np.log10(1 + hz / 700)
12
+
13
+ @staticmethod
14
+ def to_hz(mel):
15
+ return 700 * (10 ** (mel / 2595) - 1)
16
+
17
+ def __init__(self, out_channels, kernel_size, sample_rate=16000, in_channels=1,
18
+ stride=1, padding=0, dilation=1, bias=False, groups=1, min_low_hz=0, min_band_hz=0):
19
+
20
+ super(SincConv_fast,self).__init__()
21
+
22
+ if in_channels != 1:
23
+ msg = "SincConv only support one input channel (here, in_channels = {%i})" % (in_channels)
24
+ raise ValueError(msg)
25
+
26
+ self.out_channels = out_channels
27
+ self.kernel_size = kernel_size
28
+
29
+ if kernel_size%2==0:
30
+ self.kernel_size=self.kernel_size+1
31
+
32
+ self.stride = stride
33
+ self.padding = padding
34
+ self.dilation = dilation
35
+
36
+ if bias:
37
+ raise ValueError('SincConv does not support bias.')
38
+ if groups > 1:
39
+ raise ValueError('SincConv does not support groups.')
40
+
41
+ self.sample_rate = sample_rate
42
+ self.min_low_hz = min_low_hz
43
+ self.min_band_hz = min_band_hz
44
+
45
+ low_hz = 0
46
+ high_hz = self.sample_rate / 2 - (self.min_low_hz + self.min_band_hz)
47
+
48
+ mel = np.linspace(self.to_mel(low_hz),
49
+ self.to_mel(high_hz),
50
+ self.out_channels + 1)
51
+ hz = self.to_hz(mel)
52
+
53
+ self.low_hz_ = nn.Parameter(torch.Tensor(hz[:-1]).view(-1, 1))
54
+
55
+ self.band_hz_ = nn.Parameter(torch.Tensor(np.diff(hz)).view(-1, 1))
56
+ n_lin=torch.linspace(0, (self.kernel_size/2)-1, steps=int((self.kernel_size/2)))
57
+ self.window_=0.54-0.46*torch.cos(2*math.pi*n_lin/self.kernel_size);
58
+
59
+ n = (self.kernel_size - 1) / 2.0
60
+ self.n_ = 2*math.pi*torch.arange(-n, 0).view(1, -1) / self.sample_rate
61
+
62
+ def forward(self, waveforms):
63
+
64
+ self.n_ = self.n_.to(waveforms.device)
65
+
66
+
67
+ self.window_ = self.window_.to(waveforms.device)
68
+
69
+ low = self.min_low_hz + torch.abs(self.low_hz_)
70
+
71
+ high = torch.clamp(low + self.min_band_hz + torch.abs(self.band_hz_),self.min_low_hz,self.sample_rate/2)
72
+ band=(high-low)[:,0]
73
+
74
+ f_times_t_low = torch.matmul(low, self.n_)
75
+ f_times_t_high = torch.matmul(high, self.n_)
76
+
77
+ band_pass_left=((torch.sin(f_times_t_high)-torch.sin(f_times_t_low))/(self.n_/2))*self.window_
78
+ band_pass_center = 2*band.view(-1,1)
79
+ band_pass_right= torch.flip(band_pass_left,dims=[1])
80
+
81
+ band_pass=torch.cat([band_pass_left,band_pass_center,band_pass_right],dim=1)
82
+
83
+
84
+ band_pass = band_pass / (2*band[:,None])
85
+
86
+ self.filters = (band_pass).view(
87
+ self.out_channels, 1, self.kernel_size)
88
+
89
+ return F.conv1d(waveforms, self.filters, stride=self.stride,
90
+ padding=self.padding, dilation=self.dilation,
91
+ bias=None, groups=1)
92
+
93
+
94
+
95
+ class Res2Block(nn.Module):
96
+ def __init__(self, nb_filts, nums=4):
97
+ super(Res2Block, self).__init__()
98
+ self.nb_filts = nb_filts
99
+ self.conv1 = nn.Conv2d(in_channels=nb_filts[0],
100
+ out_channels=nb_filts[1],
101
+ kernel_size=1,
102
+ padding=0,
103
+ stride=1)
104
+ self.bn1 = nn.BatchNorm2d(num_features=nb_filts[1])
105
+ self.relu = nn.ReLU(inplace=True)
106
+ self.nums = nums
107
+ self.SE = SE_Block(nb_filts[1])
108
+
109
+ convs = []
110
+ bns = []
111
+
112
+ for i in range(self.nums):
113
+ convs.append(nn.Conv2d(in_channels=(nb_filts[1]// self.nums),
114
+ out_channels=(nb_filts[1] //self.nums),
115
+ kernel_size=3,
116
+ stride=1,
117
+ padding=1))
118
+ bns.append(nn.BatchNorm2d((nb_filts[1] //self.nums)))
119
+
120
+ self.convs = nn.ModuleList(convs)
121
+ self.bns = nn.ModuleList(bns)
122
+
123
+
124
+ self.conv3 = nn.Conv2d(in_channels=nb_filts[1],
125
+ out_channels=nb_filts[1],
126
+ kernel_size=1,
127
+ padding=0,
128
+ stride=1)
129
+ self.bn3 = nn.BatchNorm2d(nb_filts[1])
130
+
131
+ if nb_filts[0] != nb_filts[1]:
132
+ self.downsample = True
133
+ self.conv_downsample = nn.Conv2d(in_channels=nb_filts[0],
134
+ out_channels=nb_filts[1],
135
+ padding=(0, 1),
136
+ kernel_size=(1, 3),
137
+ stride=1)
138
+ else:
139
+ self.downsample = False
140
+
141
+ self.mp = nn.MaxPool2d((1,3))
142
+
143
+ def forward(self, x):
144
+ residual = x
145
+ out = self.conv1(x)
146
+ out = self.bn1(out)
147
+ out = self.relu(out)
148
+ spx = torch.split(out, self.nb_filts[1]//self.nums, 1)
149
+ for i in range(self.nums):
150
+ if i==0:
151
+ sp = spx[i]
152
+ else:
153
+ sp += spx[i]
154
+ sp = self.convs[i](sp)
155
+ sp = self.bns[i](sp)
156
+
157
+ if i==0:
158
+ out = sp
159
+ else:
160
+ out = torch.cat((out,sp),1)
161
+ out = self.conv3(out)
162
+ out = self.bn3(out)
163
+ out = self.SE(out)
164
+
165
+ if self.downsample:
166
+ residual = self.conv_downsample(residual)
167
+ out += residual
168
+ out = self.relu(out)
169
+ out = self.mp(out)
170
+ return out
171
+
172
+
173
+ class SE_Block(nn.Module):
174
+ "credits: https://github.com/moskomule/senet.pytorch/blob/master/senet/se_module.py#L4"
175
+ def __init__(self, c, r=8):
176
+ super().__init__()
177
+ self.squeeze = nn.AdaptiveAvgPool2d(1)
178
+ self.excitation = nn.Sequential(
179
+ nn.Linear(c, c // r, bias=False),
180
+ nn.ReLU(inplace=True),
181
+ nn.Linear(c // r, c, bias=False),
182
+ nn.Sigmoid()
183
+ )
184
+
185
+ def forward(self, x):
186
+ bs, c, _, _ = x.shape
187
+ y = self.squeeze(x).view(bs, c)
188
+ y = self.excitation(y).view(bs, c, 1, 1)
189
+ return x * y.expand_as(x)
190
+
191
+ class Encoder(nn.Module):
192
+ def __init__(self):
193
+ super().__init__()
194
+
195
+ filts = [70, [1, 32], [32, 32], [32, 64], [64, 64]]
196
+
197
+ self.sinc_conv = SincConv_fast(out_channels=filts[0],
198
+ kernel_size=128,
199
+ )
200
+
201
+ self.first_bn = nn.BatchNorm2d(num_features=1)
202
+ self.selu = nn.SELU(inplace=True)
203
+
204
+ self.res_encoder = nn.Sequential(
205
+ nn.Sequential(Res2Block(nb_filts=filts[1])),
206
+ nn.Sequential(Res2Block(nb_filts=filts[2])),
207
+ nn.Sequential(Res2Block(nb_filts=filts[3])),
208
+ nn.Sequential(Res2Block(nb_filts=filts[4])),
209
+ nn.Sequential(Res2Block(nb_filts=filts[4])),
210
+ nn.Sequential(Res2Block(nb_filts=filts[4])))
211
+
212
+ def forward(self, x):
213
+ x = x.unsqueeze(1)
214
+
215
+ x = self.sinc_conv(x)
216
+ x = x.unsqueeze(dim=1)
217
+
218
+ x = F.max_pool2d(torch.abs(x), (3, 3))
219
+ x = self.first_bn(x)
220
+ x = self.selu(x)
221
+
222
+
223
+ e = self.res_encoder(x)
224
+ return e
225
+
226
+
227
+ import torch
228
+ import torch.nn as nn
229
+ from torch.nn.utils import weight_norm
230
+
231
+
232
+ class Chomp1d(nn.Module):
233
+ def __init__(self, chomp_size):
234
+ super(Chomp1d, self).__init__()
235
+ self.chomp_size = chomp_size
236
+
237
+ def forward(self, x):
238
+ return x[:, :, :-self.chomp_size].contiguous()
239
+
240
+
241
+ class TemporalBlock(nn.Module):
242
+ def __init__(self, n_inputs, n_outputs, kernel_size, stride, dilation, padding, dropout=0.2):
243
+ super(TemporalBlock, self).__init__()
244
+ self.conv1 = weight_norm(nn.Conv1d(n_inputs, n_outputs, kernel_size,
245
+ stride=stride, padding=padding, dilation=dilation))
246
+ self.chomp1 = Chomp1d(padding)
247
+ self.relu1 = nn.ReLU()
248
+ self.dropout1 = nn.Dropout(dropout)
249
+
250
+ self.conv2 = weight_norm(nn.Conv1d(n_outputs, n_outputs, kernel_size,
251
+ stride=stride, padding=padding, dilation=dilation))
252
+ self.chomp2 = Chomp1d(padding)
253
+ self.relu2 = nn.ReLU()
254
+ self.dropout2 = nn.Dropout(dropout)
255
+
256
+ self.net = nn.Sequential(self.conv1, self.chomp1, self.relu1, self.dropout1,
257
+ self.conv2, self.chomp2, self.relu2, self.dropout2)
258
+ self.downsample = nn.Conv1d(n_inputs, n_outputs, 1) if n_inputs != n_outputs else None
259
+ self.relu = nn.ReLU()
260
+ self.init_weights()
261
+
262
+ def init_weights(self):
263
+ self.conv1.weight.data.normal_(0, 0.01)
264
+ self.conv2.weight.data.normal_(0, 0.01)
265
+ if self.downsample is not None:
266
+ self.downsample.weight.data.normal_(0, 0.01)
267
+
268
+ def forward(self, x):
269
+ out = self.net(x)
270
+ res = x if self.downsample is None else self.downsample(x)
271
+ return self.relu(out + res)
272
+
273
+
274
+ class TemporalConvNet(nn.Module):
275
+ def __init__(self, num_inputs, num_channels, kernel_size=2, dropout=0.2):
276
+ super(TemporalConvNet, self).__init__()
277
+ layers = []
278
+ num_levels = len(num_channels)
279
+ for i in range(num_levels):
280
+ dilation_size = 2 ** i
281
+ in_channels = num_inputs if i == 0 else num_channels[i-1]
282
+ out_channels = num_channels[i]
283
+ layers += [TemporalBlock(in_channels, out_channels, kernel_size, stride=1, dilation=dilation_size,
284
+ padding=(kernel_size-1) * dilation_size, dropout=dropout)]
285
+
286
+ self.network = nn.Sequential(*layers)
287
+
288
+ def forward(self, x):
289
+ return self.network(x)
290
+
291
+ class TestModel(nn.Module):
292
+ def __init__(self):
293
+ super().__init__()
294
+ self.encoder = Encoder()
295
+ self.tempCNN1 = TemporalConvNet(64,[72,36,24,12,6])
296
+ self.tempCNN2 = TemporalConvNet(64,[72,36,24,12,6])
297
+ self.relu = nn.ReLU(0.1)
298
+
299
+ self.pooling = nn.AdaptiveAvgPool2d((1, 1))
300
+
301
+ self.linear1 = nn.Linear(138,4)
302
+ self.linear2 = nn.Linear(174,4)
303
+ self.linear3 = nn.Linear(8,54)
304
+ self.linear4 = nn.Linear(54,2)
305
+ self.drop = nn.Dropout(p=0.2)
306
+
307
+
308
+ def forward(self, x):
309
+ x = self.encoder(x)
310
+ matrix1, _ = torch.max(x, dim=2) # T
311
+ matrix2, _ = torch.max(x, dim=3) # S
312
+ x1 = self.tempCNN1(matrix2)
313
+ x1 = torch.flatten(x1,1,2)
314
+ x1 = self.linear1(x1)
315
+ x1 = self.drop(x1)
316
+ x1 = self.relu(x1)
317
+
318
+ x2 = self.tempCNN2(matrix1)
319
+ x2 = torch.flatten(x2,1,2)
320
+ x2 = self.linear2(x2)
321
+ x2 = self.drop(x2)
322
+ x2 = self.relu(x2)
323
+
324
+ last_layer =self.relu(self.linear3(torch.cat((x1,x2), dim=1)))
325
+ return last_layer, self.linear4(last_layer)
326
+
evaluate.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone evaluation for Res2TCNGuard.
2
+
3
+ The network definition lives in ``_net.py`` (in this repo). This script loads
4
+ the pretrained checkpoint ``best_1.495.pth`` and scores audio, returning a
5
+ bona-fide score where **higher = more bona fide**.
6
+
7
+ Dependencies: torch, numpy (plus soundfile + scipy for the file demo below).
8
+
9
+ python evaluate.py path/to/audio.wav
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ from _net import TestModel
17
+
18
+ CUT = 64600 # fixed input length the classifier head requires
19
+ SAMPLE_RATE = 16000 # model operates on 16 kHz mono audio
20
+
21
+
22
+ def pad_fixed(x: np.ndarray, max_len: int = CUT) -> np.ndarray:
23
+ """Deterministic window: first ``max_len`` samples; tile-repeat if shorter.
24
+
25
+ This is exactly the windowing used to produce the Arena scores (no random
26
+ crop), so results are reproducible.
27
+ """
28
+ x = np.asarray(x, dtype=np.float32).reshape(-1)
29
+ n = x.shape[0]
30
+ if n >= max_len:
31
+ return x[:max_len]
32
+ reps = max_len // n + 1
33
+ return np.tile(x, reps)[:max_len].astype(np.float32)
34
+
35
+
36
+ def load_model(ckpt: str = "best_1.495.pth", device: str = "cpu") -> TestModel:
37
+ model = TestModel()
38
+ sd = torch.load(ckpt, map_location="cpu")
39
+ sd = sd.get("state_dict", sd) # accept raw state_dict or wrapped
40
+ model.load_state_dict(sd, strict=True)
41
+ return model.eval().to(device)
42
+
43
+
44
+ @torch.no_grad()
45
+ def score(model: TestModel, audio: np.ndarray, device: str = "cpu") -> float:
46
+ """Score one utterance (float32 mono 16 kHz waveform). Higher = bona fide."""
47
+ x = torch.from_numpy(pad_fixed(audio))[None].to(device) # (1, 64600)
48
+ _, logits = model(x) # (1, 2)
49
+ return float(logits[0, 1])
50
+
51
+
52
+ if __name__ == "__main__":
53
+ import sys
54
+ from math import gcd
55
+
56
+ import soundfile as sf
57
+ from scipy.signal import resample_poly
58
+
59
+ audio, sr = sf.read(sys.argv[1])
60
+ if audio.ndim == 2:
61
+ audio = audio.mean(axis=1)
62
+ audio = audio.astype(np.float32)
63
+ if sr != SAMPLE_RATE:
64
+ g = gcd(int(sr), SAMPLE_RATE)
65
+ audio = resample_poly(audio, SAMPLE_RATE // g, int(sr) // g).astype(np.float32)
66
+
67
+ model = load_model(device="cpu")
68
+ print(f"bona-fide score: {score(model, audio):.6f} (higher = more bona fide)")
res2tcnguard.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os
3
+ import numpy as np
4
+ import torch
5
+ from speech_spoof_bench.model import AntiSpoofingModel
6
+ from _net import TestModel
7
+
8
+ _CKPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_1.495.pth")
9
+ _CUT = 64600
10
+
11
+
12
+ def pad_fixed(x: np.ndarray, max_len: int = _CUT) -> np.ndarray:
13
+ """Deterministic: first max_len samples; tile-repeat if shorter."""
14
+ x = np.asarray(x, dtype=np.float32).reshape(-1)
15
+ n = x.shape[0]
16
+ if n >= max_len:
17
+ return x[:max_len]
18
+ reps = max_len // n + 1
19
+ return np.tile(x, reps)[:max_len].astype(np.float32)
20
+
21
+
22
+ class Res2TCNGuard(AntiSpoofingModel):
23
+ name = "Res2TCNGuard"
24
+ expected_sample_rate = 16000
25
+ batch_size = 4 # tuned by perf sweep 2026-05-31 (throughput plateaus; peak at bs=4)
26
+
27
+ def load(self) -> None:
28
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
29
+ net = TestModel()
30
+ sd = torch.load(_CKPT, map_location="cpu")
31
+ sd = sd.get("state_dict", sd)
32
+ net.load_state_dict(sd, strict=True)
33
+ self.net = net.eval().to(self.device)
34
+
35
+ @torch.no_grad()
36
+ def score_batch(self, audios, srs):
37
+ x = np.stack([pad_fixed(a) for a in audios])
38
+ xt = torch.from_numpy(x).to(self.device)
39
+ _, logits = self.net(xt)
40
+ return logits[:, 1].detach().cpu().float().tolist()
41
+
42
+ def unload(self) -> None:
43
+ self.net = None