netemb 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.
- netemb/__init__.py +15 -0
- netemb/_io.py +68 -0
- netemb/_memcheck.py +34 -0
- netemb/_train.py +445 -0
- netemb/_version.py +1 -0
- netemb/estimator.py +347 -0
- netemb/features.py +333 -0
- netemb/generators/__init__.py +38 -0
- netemb/generators/_base.py +50 -0
- netemb/generators/barabasi_albert.py +37 -0
- netemb/generators/er.py +32 -0
- netemb/generators/forest_fire.py +109 -0
- netemb/generators/modular.py +37 -0
- netemb/generators/power_law_cluster.py +33 -0
- netemb/generators/random_geometric.py +32 -0
- netemb/generators/reciprocal.py +70 -0
- netemb/generators/sbm.py +47 -0
- netemb/generators/watts_strogatz.py +58 -0
- netemb/loss.py +71 -0
- netemb/model.py +42 -0
- netemb-0.4.0.dist-info/METADATA +79 -0
- netemb-0.4.0.dist-info/RECORD +25 -0
- netemb-0.4.0.dist-info/WHEEL +5 -0
- netemb-0.4.0.dist-info/licenses/LICENSE +661 -0
- netemb-0.4.0.dist-info/top_level.txt +1 -0
netemb/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .estimator import NetworkEmbedder
|
|
3
|
+
from .features import LaplacianSpectrum, FiedlerVector, SpectrumWithFiedler, FeatureExtractor
|
|
4
|
+
from .generators import discover_generators, GeneratorProtocol
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__version__",
|
|
8
|
+
"NetworkEmbedder",
|
|
9
|
+
"LaplacianSpectrum",
|
|
10
|
+
"FiedlerVector",
|
|
11
|
+
"SpectrumWithFiedler",
|
|
12
|
+
"FeatureExtractor",
|
|
13
|
+
"discover_generators",
|
|
14
|
+
"GeneratorProtocol",
|
|
15
|
+
]
|
netemb/_io.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .estimator import NetworkEmbedder
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_data_dir
|
|
12
|
+
|
|
13
|
+
from .model import EmbeddingNet
|
|
14
|
+
from . import features as _features
|
|
15
|
+
|
|
16
|
+
_DEFAULT_SAVE_PATH = Path(user_data_dir("netemb")) / "model.pt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save(embedder: NetworkEmbedder, path: str | Path | None = None) -> Path:
|
|
20
|
+
"""Persist model weights and hyperparameters to disk.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
embedder : NetworkEmbedder
|
|
25
|
+
path : str or Path, optional
|
|
26
|
+
Target file. Defaults to the platform user-data directory
|
|
27
|
+
(e.g. ~/Library/Application Support/netemb/model.pt on macOS).
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
Path — the path actually written to.
|
|
32
|
+
"""
|
|
33
|
+
if embedder.model_ is None:
|
|
34
|
+
raise RuntimeError("Call fit() before save().")
|
|
35
|
+
|
|
36
|
+
target = Path(path) if path is not None else _DEFAULT_SAVE_PATH
|
|
37
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
torch.save({
|
|
40
|
+
'max_nodes': embedder.max_nodes,
|
|
41
|
+
'emb_dim': embedder.emb_dim,
|
|
42
|
+
'hidden_dims': embedder.hidden_dims,
|
|
43
|
+
'activation': embedder.activation,
|
|
44
|
+
'dropout': embedder.dropout,
|
|
45
|
+
'feature_extractor': _features.serialize_feature_extractor(embedder.feature_extractor),
|
|
46
|
+
'input_dim': embedder.model_.gates.numel(),
|
|
47
|
+
'state_dict': embedder.model_.state_dict(),
|
|
48
|
+
}, target)
|
|
49
|
+
print(f"Model saved to {target}")
|
|
50
|
+
return target
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(path: str | Path | None = None) -> dict[str, Any]:
|
|
54
|
+
"""Load a previously saved NetworkEmbedder from disk.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
path : str or Path, optional
|
|
59
|
+
File to read from. Defaults to the platform user-data directory.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
dict — checkpoint dictionary (deserialized by the caller)
|
|
64
|
+
"""
|
|
65
|
+
target = Path(path) if path is not None else _DEFAULT_SAVE_PATH
|
|
66
|
+
checkpoint = torch.load(target, weights_only=True)
|
|
67
|
+
print(f"Model loaded from {target}")
|
|
68
|
+
return checkpoint
|
netemb/_memcheck.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Lightweight RSS memory helpers for profiling netemb internals."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def mem_mb() -> float:
|
|
8
|
+
try:
|
|
9
|
+
import psutil
|
|
10
|
+
except ImportError:
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"psutil is required for memory profiling. "
|
|
13
|
+
"Install with: pip install psutil or pip install netemb[profile]"
|
|
14
|
+
) from None
|
|
15
|
+
return psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def checkpoint(label: str) -> None:
|
|
19
|
+
print(f"[memcheck] {label}: {mem_mb():.1f} MB RSS", flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def malloc_trim() -> None:
|
|
23
|
+
"""Ask glibc to return freed heap pages to the OS (Linux only, no-op elsewhere).
|
|
24
|
+
|
|
25
|
+
On Linux, glibc's ptmalloc keeps freed memory in its internal pool instead
|
|
26
|
+
of returning it to the OS. Calling this after each generator run prevents
|
|
27
|
+
the RSS from growing monotonically across the dataset assembly loop.
|
|
28
|
+
"""
|
|
29
|
+
if sys.platform.startswith("linux"):
|
|
30
|
+
try:
|
|
31
|
+
import ctypes
|
|
32
|
+
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
netemb/_train.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from .features import FeatureExtractor
|
|
15
|
+
from .loss import pairwise_distance_loss
|
|
16
|
+
|
|
17
|
+
# Number of networks generated per parallel task. It is fixed (rather than
|
|
18
|
+
# derived from n_jobs) so that, for a given random_state, the generated
|
|
19
|
+
# data is identical whatever the number of parallel jobs.
|
|
20
|
+
_CHUNK_SIZE = 10
|
|
21
|
+
|
|
22
|
+
_THREAD_ENV_VARS = (
|
|
23
|
+
'OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS',
|
|
24
|
+
'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_n_jobs(n_jobs: int, warn: bool = True) -> int:
|
|
29
|
+
"""Validate n_jobs and turn it into an actual number of worker processes.
|
|
30
|
+
|
|
31
|
+
-1 means all CPUs this process may use; a positive int is used as is,
|
|
32
|
+
with a warning (if warn) when it exceeds the usable CPUs.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(n_jobs, bool) or not isinstance(n_jobs, int) or (n_jobs < 1 and n_jobs != -1):
|
|
35
|
+
raise ValueError(f"n_jobs must be -1 or a positive int; got {n_jobs!r}")
|
|
36
|
+
if n_jobs == 1:
|
|
37
|
+
return 1
|
|
38
|
+
usable = usable_cpus()
|
|
39
|
+
if n_jobs == -1:
|
|
40
|
+
return usable
|
|
41
|
+
if warn and n_jobs > usable:
|
|
42
|
+
warnings.warn(
|
|
43
|
+
f"n_jobs={n_jobs}, but this process can only use {usable} CPU(s) "
|
|
44
|
+
f"(CPU affinity or cgroup/Slurm limits), so the {n_jobs} parallel jobs "
|
|
45
|
+
f"will share them. On Slurm, request the CPUs for a single task, "
|
|
46
|
+
f"e.g. --ntasks=1 --cpus-per-task={n_jobs}.",
|
|
47
|
+
RuntimeWarning, stacklevel=4,
|
|
48
|
+
)
|
|
49
|
+
return n_jobs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usable_cpus() -> int:
|
|
53
|
+
"""Number of CPUs this process may actually use.
|
|
54
|
+
|
|
55
|
+
Accounts for CPU affinity and cgroup quotas (e.g. set by Slurm), which
|
|
56
|
+
can be far fewer than the cores of the machine.
|
|
57
|
+
"""
|
|
58
|
+
from joblib import cpu_count
|
|
59
|
+
return cpu_count()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_memory(feature_extractor: FeatureExtractor, max_nodes: int, n_jobs: int) -> None:
|
|
63
|
+
"""Warn when n_jobs parallel eigendecompositions are unlikely to fit in RAM.
|
|
64
|
+
|
|
65
|
+
Only the dense diagonalisation of the built-in extractors is estimated
|
|
66
|
+
(Laplacian, eigenvectors and LAPACK workspace), sized for directed
|
|
67
|
+
graphs, whose magnetic Laplacian is complex (twice the memory of the
|
|
68
|
+
real Laplacian of undirected graphs); the memory of the NetworkX graph
|
|
69
|
+
itself comes on top of it.
|
|
70
|
+
"""
|
|
71
|
+
from .features import LaplacianSpectrum, FiedlerVector, SpectrumWithFiedler
|
|
72
|
+
if n_jobs < 2 or not isinstance(feature_extractor, (LaplacianSpectrum, FiedlerVector, SpectrumWithFiedler)):
|
|
73
|
+
return
|
|
74
|
+
k = getattr(feature_extractor, 'k', None)
|
|
75
|
+
if k is not None and max_nodes > k + 1:
|
|
76
|
+
return # sparse solver: memory does not scale with max_nodes**2
|
|
77
|
+
n_matrices = 2 if isinstance(feature_extractor, LaplacianSpectrum) else 4
|
|
78
|
+
per_job = n_matrices * 16 * max_nodes ** 2
|
|
79
|
+
available = _available_memory()
|
|
80
|
+
if available is None or n_jobs * per_job <= available:
|
|
81
|
+
return
|
|
82
|
+
suggested = max(1, int(available // per_job))
|
|
83
|
+
warnings.warn(
|
|
84
|
+
f"Creating networks with up to {max_nodes} nodes needs up to about "
|
|
85
|
+
f"{per_job / 1024**3:.1f} GB per parallel job (for directed networks), i.e. "
|
|
86
|
+
f"{n_jobs * per_job / 1024**3:.1f} GB for n_jobs={n_jobs}, but only "
|
|
87
|
+
f"{available / 1024**3:.1f} GB of memory is available. Consider "
|
|
88
|
+
f"n_jobs={suggested}.",
|
|
89
|
+
RuntimeWarning, stacklevel=3,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _available_memory() -> int | None:
|
|
94
|
+
try:
|
|
95
|
+
import psutil
|
|
96
|
+
return psutil.virtual_memory().available
|
|
97
|
+
except ImportError:
|
|
98
|
+
pass
|
|
99
|
+
try:
|
|
100
|
+
return os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES')
|
|
101
|
+
except (ValueError, OSError, AttributeError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _threads_per_worker(n_workers: int) -> int:
|
|
106
|
+
"""Maths-library threads each worker may use: its share of the usable CPUs.
|
|
107
|
+
|
|
108
|
+
Batch systems often set OMP_NUM_THREADS etc. to all the allocated CPUs;
|
|
109
|
+
inherited by every worker, that would run n_workers times too many
|
|
110
|
+
threads, which then mostly wait for each other. A lower value set by
|
|
111
|
+
the user is kept.
|
|
112
|
+
"""
|
|
113
|
+
n_threads = max(1, usable_cpus() // n_workers)
|
|
114
|
+
for v in _THREAD_ENV_VARS:
|
|
115
|
+
try:
|
|
116
|
+
n_threads = min(n_threads, max(1, int(os.environ[v])))
|
|
117
|
+
except (KeyError, ValueError):
|
|
118
|
+
pass
|
|
119
|
+
return n_threads
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_chunk(g: Callable, kwargs: dict, seed: int,
|
|
123
|
+
n_threads: int | None = None) -> tuple[np.ndarray, np.ndarray, float]:
|
|
124
|
+
"""Generate one chunk of networks with a dedicated seed.
|
|
125
|
+
|
|
126
|
+
n_threads, when given (worker processes), caps the threads used by
|
|
127
|
+
torch and, if threadpoolctl is installed, by the BLAS/OpenMP libraries
|
|
128
|
+
already loaded in the worker, in addition to the environment variables
|
|
129
|
+
set when the worker starts.
|
|
130
|
+
|
|
131
|
+
Returns numpy arrays (cheaper and safer to send back from worker
|
|
132
|
+
processes than torch tensors) plus the compute time in seconds.
|
|
133
|
+
"""
|
|
134
|
+
from contextlib import nullcontext
|
|
135
|
+
from ._memcheck import malloc_trim
|
|
136
|
+
random.seed(seed)
|
|
137
|
+
np.random.seed(seed)
|
|
138
|
+
torch.manual_seed(seed)
|
|
139
|
+
limits = nullcontext()
|
|
140
|
+
if n_threads is not None:
|
|
141
|
+
torch.set_num_threads(n_threads)
|
|
142
|
+
try:
|
|
143
|
+
from threadpoolctl import threadpool_limits
|
|
144
|
+
limits = threadpool_limits(limits=n_threads)
|
|
145
|
+
except ImportError:
|
|
146
|
+
pass
|
|
147
|
+
t0 = time.perf_counter()
|
|
148
|
+
with limits:
|
|
149
|
+
X, y = g(**kwargs)
|
|
150
|
+
elapsed = time.perf_counter() - t0
|
|
151
|
+
malloc_trim()
|
|
152
|
+
return np.asarray(X), np.asarray(y), elapsed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _run_chunk_in_process(g: Callable, kwargs: dict, seed: int) -> tuple[np.ndarray, np.ndarray, float]:
|
|
156
|
+
"""_run_chunk, restoring the caller's RNG states afterwards.
|
|
157
|
+
|
|
158
|
+
This keeps the main-process RNGs (used e.g. to initialise the network
|
|
159
|
+
weights) in the same state as when chunks run in worker processes.
|
|
160
|
+
"""
|
|
161
|
+
states = random.getstate(), np.random.get_state(), torch.get_rng_state()
|
|
162
|
+
try:
|
|
163
|
+
return _run_chunk(g, kwargs, seed)
|
|
164
|
+
finally:
|
|
165
|
+
random.setstate(states[0])
|
|
166
|
+
np.random.set_state(states[1])
|
|
167
|
+
torch.set_rng_state(states[2])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fmt_time(seconds: float) -> str:
|
|
171
|
+
seconds = int(round(seconds))
|
|
172
|
+
if seconds < 60:
|
|
173
|
+
return f"{seconds}s"
|
|
174
|
+
if seconds < 3600:
|
|
175
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
176
|
+
return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _Progress:
|
|
180
|
+
"""Progress report for the creation of synthetic networks.
|
|
181
|
+
|
|
182
|
+
In a terminal a single line is updated in place; otherwise (log files,
|
|
183
|
+
some notebooks) a line is printed every 10% of the total.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, gen_totals: dict[tuple[str, str], int], enabled: bool):
|
|
187
|
+
self.enabled = enabled
|
|
188
|
+
self.gen_totals = gen_totals
|
|
189
|
+
self.gen_done = dict.fromkeys(gen_totals, 0)
|
|
190
|
+
self.gen_time = dict.fromkeys(gen_totals, 0.0)
|
|
191
|
+
self.total = sum(gen_totals.values())
|
|
192
|
+
self.done = 0
|
|
193
|
+
self.t0 = time.perf_counter()
|
|
194
|
+
self.tty = sys.stdout.isatty()
|
|
195
|
+
self.next_decile = 1
|
|
196
|
+
|
|
197
|
+
def update(self, key: tuple[str, str], n: int, compute_time: float) -> None:
|
|
198
|
+
self.done += n
|
|
199
|
+
self.gen_done[key] += n
|
|
200
|
+
self.gen_time[key] += compute_time
|
|
201
|
+
if not self.enabled:
|
|
202
|
+
return
|
|
203
|
+
if self.gen_done[key] == self.gen_totals[key]:
|
|
204
|
+
label, name = key
|
|
205
|
+
self._print(f" [{label}] {name}: {self.gen_totals[key]} networks, "
|
|
206
|
+
f"{_fmt_time(self.gen_time[key])} compute time", final=True)
|
|
207
|
+
if self.tty:
|
|
208
|
+
self._print(self._line(), final=False)
|
|
209
|
+
elif self.done * 10 >= self.next_decile * self.total:
|
|
210
|
+
while self.done * 10 >= self.next_decile * self.total:
|
|
211
|
+
self.next_decile += 1
|
|
212
|
+
self._print(self._line(), final=True)
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
if self.enabled and self.tty:
|
|
216
|
+
print(flush=True)
|
|
217
|
+
|
|
218
|
+
def _line(self) -> str:
|
|
219
|
+
elapsed = time.perf_counter() - self.t0
|
|
220
|
+
line = (f"Creating networks: {self.done}/{self.total} "
|
|
221
|
+
f"({100 * self.done / self.total:.0f}%) | elapsed {_fmt_time(elapsed)}")
|
|
222
|
+
if 0 < self.done < self.total:
|
|
223
|
+
line += f" | ~{_fmt_time(elapsed * (self.total - self.done) / self.done)} left"
|
|
224
|
+
return line
|
|
225
|
+
|
|
226
|
+
def _print(self, text: str, final: bool) -> None:
|
|
227
|
+
if self.tty:
|
|
228
|
+
print('\r\033[K' + text, end='\n' if final else '', flush=True)
|
|
229
|
+
else:
|
|
230
|
+
print(text, flush=True)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def assemble_datasets(
|
|
234
|
+
generators: list[Callable],
|
|
235
|
+
sizes: list[int],
|
|
236
|
+
max_nodes: int,
|
|
237
|
+
feature_extractor: FeatureExtractor | None = None,
|
|
238
|
+
n_jobs: int = 1,
|
|
239
|
+
verbose: bool = False,
|
|
240
|
+
labels: list[str] | None = None,
|
|
241
|
+
profile_memory: bool = False,
|
|
242
|
+
) -> list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
|
|
243
|
+
"""Generate one or more datasets (e.g. training and evaluation) from generators.
|
|
244
|
+
|
|
245
|
+
Each generator is called on chunks of _CHUNK_SIZE networks, each chunk
|
|
246
|
+
with its own seed drawn from numpy's global RNG, so that the result for
|
|
247
|
+
a given seed does not depend on n_jobs. All chunks of all datasets are
|
|
248
|
+
processed by the same pool of worker processes.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
generators : list of GeneratorProtocol
|
|
253
|
+
sizes : list of int
|
|
254
|
+
Number of graphs per generator, one entry per dataset.
|
|
255
|
+
max_nodes : int
|
|
256
|
+
feature_extractor : FeatureExtractor or None
|
|
257
|
+
Passed on to each generator that accepts a feature_extractor
|
|
258
|
+
keyword argument, so training data matches whatever extractor is
|
|
259
|
+
configured on the NetworkEmbedder. Generators that don't accept
|
|
260
|
+
it (e.g. custom extra_generators) fall back to their own default.
|
|
261
|
+
n_jobs : int, default 1
|
|
262
|
+
Number of worker processes; -1 uses all available cores and 1
|
|
263
|
+
generates networks one at a time in the current process.
|
|
264
|
+
verbose : bool, default False
|
|
265
|
+
Print the progress of the creation process.
|
|
266
|
+
labels : list of str or None
|
|
267
|
+
Names of the datasets used in progress messages.
|
|
268
|
+
profile_memory : bool
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
list of (X, y, groups), one per dataset, with
|
|
273
|
+
X : torch.Tensor, shape (N, feature_len)
|
|
274
|
+
y : torch.Tensor, shape (N,)
|
|
275
|
+
groups : torch.Tensor, shape (N,), dtype long
|
|
276
|
+
"""
|
|
277
|
+
n_jobs = resolve_n_jobs(n_jobs)
|
|
278
|
+
labels = labels if labels is not None else [f"set {i}" for i in range(len(sizes))]
|
|
279
|
+
if profile_memory:
|
|
280
|
+
from ._memcheck import checkpoint
|
|
281
|
+
|
|
282
|
+
# One task per (dataset, generator, chunk); seeds follow this canonical order.
|
|
283
|
+
tasks = []
|
|
284
|
+
for s, size in enumerate(sizes):
|
|
285
|
+
for gi, g in enumerate(generators):
|
|
286
|
+
kwargs = dict(max_nodes=max_nodes)
|
|
287
|
+
if feature_extractor is not None and 'feature_extractor' in inspect.signature(g).parameters:
|
|
288
|
+
kwargs['feature_extractor'] = feature_extractor
|
|
289
|
+
for c, start in enumerate(range(0, size, _CHUNK_SIZE)):
|
|
290
|
+
tasks.append((s, gi, c, dict(kwargs, n=min(_CHUNK_SIZE, size - start))))
|
|
291
|
+
seeds = np.random.SeedSequence(int(np.random.randint(0, 2**32))).spawn(len(tasks))
|
|
292
|
+
seeds = [int(ss.generate_state(1)[0]) for ss in seeds]
|
|
293
|
+
# Interleave generators, so that progress and time estimates are representative.
|
|
294
|
+
order = sorted(range(len(tasks)), key=lambda t: (tasks[t][0], tasks[t][2], tasks[t][1]))
|
|
295
|
+
|
|
296
|
+
n_workers = min(n_jobs, len(tasks))
|
|
297
|
+
progress = _Progress(
|
|
298
|
+
{(labels[s], g.__name__): size for s, size in enumerate(sizes) for g in generators},
|
|
299
|
+
enabled=verbose,
|
|
300
|
+
)
|
|
301
|
+
if verbose:
|
|
302
|
+
print(f"Creating {' + '.join(f'{len(generators) * n} {l}' for n, l in zip(sizes, labels))} "
|
|
303
|
+
f"networks with {n_workers} parallel job(s)"
|
|
304
|
+
+ (f" on {usable_cpus()} usable CPU(s)" if n_workers > 1 else ""), flush=True)
|
|
305
|
+
|
|
306
|
+
results = {}
|
|
307
|
+
|
|
308
|
+
def collect(t, result):
|
|
309
|
+
s, gi, c, _ = tasks[t]
|
|
310
|
+
results[s, gi, c] = result[:2]
|
|
311
|
+
progress.update((labels[s], generators[gi].__name__), len(result[1]), result[2])
|
|
312
|
+
if profile_memory and progress.gen_done[labels[s], generators[gi].__name__] == sizes[s]:
|
|
313
|
+
checkpoint(f" after generator {generators[gi].__name__} ({labels[s]})")
|
|
314
|
+
|
|
315
|
+
if n_workers <= 1:
|
|
316
|
+
for t in order:
|
|
317
|
+
s, gi, c, kwargs = tasks[t]
|
|
318
|
+
collect(t, _run_chunk_in_process(generators[gi], kwargs, seeds[t]))
|
|
319
|
+
else:
|
|
320
|
+
from concurrent.futures import as_completed
|
|
321
|
+
from joblib.externals.loky import get_reusable_executor
|
|
322
|
+
if profile_memory:
|
|
323
|
+
print("[memcheck] n_jobs > 1: eigendecomposition checkpoints run in worker "
|
|
324
|
+
"processes and are not reported", flush=True)
|
|
325
|
+
n_threads = _threads_per_worker(n_workers)
|
|
326
|
+
env = {v: str(n_threads) for v in _THREAD_ENV_VARS}
|
|
327
|
+
executor = get_reusable_executor(max_workers=n_workers, env=env)
|
|
328
|
+
try:
|
|
329
|
+
futures = {executor.submit(_run_chunk, generators[tasks[t][1]], tasks[t][3], seeds[t], n_threads): t
|
|
330
|
+
for t in order}
|
|
331
|
+
for future in as_completed(futures):
|
|
332
|
+
collect(futures[future], future.result())
|
|
333
|
+
finally:
|
|
334
|
+
# Release the workers' memory before training starts.
|
|
335
|
+
executor.shutdown(wait=True, kill_workers=True)
|
|
336
|
+
progress.close()
|
|
337
|
+
|
|
338
|
+
datasets = []
|
|
339
|
+
for s, size in enumerate(sizes):
|
|
340
|
+
n_chunks = len(range(0, size, _CHUNK_SIZE))
|
|
341
|
+
Xs, ys, gs = [], [], []
|
|
342
|
+
for gi in range(len(generators)):
|
|
343
|
+
for c in range(n_chunks):
|
|
344
|
+
X_c, y_c = results[s, gi, c]
|
|
345
|
+
Xs.append(X_c)
|
|
346
|
+
ys.append(y_c)
|
|
347
|
+
gs.append(np.full(len(y_c), gi, dtype=np.int64))
|
|
348
|
+
X = torch.from_numpy(np.concatenate(Xs, axis=0))
|
|
349
|
+
y = torch.from_numpy(np.concatenate(ys, axis=0))
|
|
350
|
+
groups = torch.from_numpy(np.concatenate(gs))
|
|
351
|
+
if profile_memory:
|
|
352
|
+
size_mb = X.element_size() * X.nelement() / 1024 / 1024
|
|
353
|
+
checkpoint(f" assemble_dataset done ({labels[s]}) — X {tuple(X.shape)}, {size_mb:.1f} MB tensor")
|
|
354
|
+
datasets.append((X, y, groups))
|
|
355
|
+
return datasets
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def assemble_dataset(
|
|
359
|
+
generators: list[Callable],
|
|
360
|
+
n_per_group: int,
|
|
361
|
+
max_nodes: int,
|
|
362
|
+
feature_extractor: FeatureExtractor | None = None,
|
|
363
|
+
profile_memory: bool = False,
|
|
364
|
+
n_jobs: int = 1,
|
|
365
|
+
verbose: bool = False,
|
|
366
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
367
|
+
"""Generate a single training or evaluation dataset; see assemble_datasets()."""
|
|
368
|
+
return assemble_datasets(generators, [n_per_group], max_nodes,
|
|
369
|
+
feature_extractor=feature_extractor, n_jobs=n_jobs,
|
|
370
|
+
verbose=verbose, profile_memory=profile_memory)[0]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_training_loop(
|
|
374
|
+
net: torch.nn.Module,
|
|
375
|
+
X: torch.Tensor,
|
|
376
|
+
y: torch.Tensor,
|
|
377
|
+
groups: torch.Tensor,
|
|
378
|
+
X_test: torch.Tensor,
|
|
379
|
+
y_test: torch.Tensor,
|
|
380
|
+
groups_test: torch.Tensor,
|
|
381
|
+
epochs: int,
|
|
382
|
+
lr: float,
|
|
383
|
+
verbose: bool,
|
|
384
|
+
profile_memory: bool = False,
|
|
385
|
+
cross_family_weight: float = 0.0,
|
|
386
|
+
cross_family_margin: float = 1.0,
|
|
387
|
+
) -> tuple[list[float], list[float]]:
|
|
388
|
+
"""Train EmbeddingNet in place and return (train_loss, eval_loss) lists.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
net : EmbeddingNet
|
|
393
|
+
X, y, groups : training tensors
|
|
394
|
+
X_test, y_test, groups_test : held-out evaluation tensors
|
|
395
|
+
epochs : int
|
|
396
|
+
lr : float
|
|
397
|
+
verbose : bool
|
|
398
|
+
profile_memory : bool
|
|
399
|
+
cross_family_weight : float, default 0.0
|
|
400
|
+
Strength of cross-family repulsion. 0.0 = original behaviour (no repulsion).
|
|
401
|
+
cross_family_margin : float, default 1.0
|
|
402
|
+
Minimum desired distance between different network families.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
train_loss : list of float
|
|
407
|
+
eval_loss : list of float
|
|
408
|
+
"""
|
|
409
|
+
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
|
|
410
|
+
train_loss = []
|
|
411
|
+
eval_loss = []
|
|
412
|
+
|
|
413
|
+
if profile_memory:
|
|
414
|
+
from ._memcheck import checkpoint
|
|
415
|
+
checkpoint("training loop START")
|
|
416
|
+
|
|
417
|
+
for epoch in range(epochs):
|
|
418
|
+
net.train()
|
|
419
|
+
optimizer.zero_grad()
|
|
420
|
+
emb = net(X)
|
|
421
|
+
loss = pairwise_distance_loss(
|
|
422
|
+
emb, y, groups,
|
|
423
|
+
cross_family_weight=cross_family_weight,
|
|
424
|
+
cross_family_margin=cross_family_margin,
|
|
425
|
+
)
|
|
426
|
+
loss.backward()
|
|
427
|
+
optimizer.step()
|
|
428
|
+
|
|
429
|
+
net.eval()
|
|
430
|
+
with torch.no_grad():
|
|
431
|
+
e_loss = pairwise_distance_loss(
|
|
432
|
+
net(X_test), y_test, groups_test,
|
|
433
|
+
cross_family_weight=cross_family_weight,
|
|
434
|
+
cross_family_margin=cross_family_margin,
|
|
435
|
+
)
|
|
436
|
+
train_loss.append(loss.item())
|
|
437
|
+
eval_loss.append(e_loss.item())
|
|
438
|
+
|
|
439
|
+
if epoch % 50 == 0 and verbose:
|
|
440
|
+
print(f"Epoch {epoch:4d} | Train loss: {loss.item():.4f} | Eval loss: {e_loss.item():.4f}")
|
|
441
|
+
|
|
442
|
+
if profile_memory and epoch % 100 == 0:
|
|
443
|
+
checkpoint(f" epoch {epoch:4d}")
|
|
444
|
+
|
|
445
|
+
return train_loss, eval_loss
|
netemb/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|