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 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"