Coverage for calorine/nep/model.py: 99%

1097 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-08 12:52 +0000

1import copy 

2import re 

3from collections.abc import Iterable 

4from dataclasses import dataclass 

5from itertools import product 

6from typing import Any 

7from warnings import warn 

8 

9import numpy as np 

10 

11from calorine.nep.io import write_nepfile as _write_nepfile 

12 

13NetworkWeights = dict[str, dict[str, np.ndarray]] 

14DescriptorWeights = dict[tuple[str, str], np.ndarray] 

15RestartParameters = dict[str, dict[str, dict[str, np.ndarray]]] 

16 

17# the `model_type` value that each model type corresponds to in `nep.in` 

18_MODEL_TYPE_TO_INT = { 

19 'potential': 0, 

20 'potential_with_charges': 0, 

21 'dipole': 1, 

22 'polarizability': 2, 

23} 

24 

25# The `nep.in` keywords that describe the model rather than the training run. These are the 

26# keywords that the `nep` executable checks against the `nep.txt` header (see 

27# `Parameters::compare_with_nep_txt` in `src/main_nep/parameters.cu` of GPUMD), plus `mode`, 

28# which `nep` rejects as an invalid keyword. `Model.write_nepfile` supplies all of them from the 

29# model and discards any value given for them, so that a value belonging to another model cannot 

30# reach the file. A supplied `mode` is replaced by `model_type`. 

31_MODEL_PARAMETERS = ( 

32 'version', 

33 'model_type', 

34 'mode', 

35 'type', 

36 'cutoff', 

37 'n_max', 

38 'basis_size', 

39 'l_max', 

40 'neuron', 

41 'zbl', 

42 'use_typewise_cutoff_zbl', 

43 'charge_mode', 

44) 

45 

46# the factor that the `nep` executable uses when `use_typewise_cutoff_zbl` carries no value 

47_TYPEWISE_CUTOFF_ZBL_FACTOR_DEFAULT = 0.7 

48 

49 

50def _nepfile_tokens(value: Any) -> list[str]: 

51 """Returns the value of a ``nep.in`` keyword as the list of whitespace separated tokens 

52 that it is written as, so that values read from a file can be compared with values taken 

53 from a model irrespective of how either is represented in Python. 

54 

55 Parameters 

56 ---------- 

57 value 

58 Value of a single ``nep.in`` keyword. 

59 """ 

60 if isinstance(value, str): 

61 return value.split() 

62 if isinstance(value, Iterable): 

63 return [f'{v}' for v in value] 

64 return [f'{value}'] 

65 

66 

67def _check_l_max_4b(l_max_4b: int | None) -> None: 

68 """Raises a ValueError unless ``l_max_4b`` is ``None`` or the integer 0 or 2. 

69 

70 `nep` writes the four-body term to ``nep.txt`` as a flag of 0 or 2, and no ``nep.in`` 

71 agrees with a ``nep.txt`` holding any other value. 

72 """ 

73 if l_max_4b is None: 

74 return 

75 if isinstance(l_max_4b, bool) or not isinstance(l_max_4b, (int, np.integer)) \ 

76 or l_max_4b not in (0, 2): 

77 raise ValueError(f'l_max_4b must be 0 or 2; got {l_max_4b!r}.') 

78 

79 

80def _same_nepfile_value(supplied: Any, model_value: Any) -> bool: 

81 """Returns whether two values of a ``nep.in`` keyword agree, comparing token by token and 

82 numerically where both tokens are numbers, so that e.g. ``6`` and ``6.0`` agree. 

83 

84 Parameters 

85 ---------- 

86 supplied 

87 Value to check, typically read from an existing ``nep.in`` file. 

88 model_value 

89 Value taken from the model, or ``None`` if the model does not set this keyword. 

90 """ 

91 if model_value is None: 

92 return False 

93 left = _nepfile_tokens(supplied) 

94 right = _nepfile_tokens(model_value) 

95 if len(left) != len(right): 

96 return False 

97 for token_left, token_right in zip(left, right): 

98 if token_left == token_right: 

99 continue 

100 try: 

101 value_left, value_right = float(token_left), float(token_right) 

102 except ValueError: 

103 return False 

104 if abs(value_left - value_right) > 1e-6 * (abs(value_left) + abs(value_right)): 

105 return False 

106 return True 

107 

108 

109def _format_nepfile_value(value: Any) -> str: 

110 """Returns the value of a ``nep.in`` keyword as it is written to file, for use in messages. 

111 

112 Parameters 

113 ---------- 

114 value 

115 Value of a single ``nep.in`` keyword. 

116 """ 

117 return ' '.join(_nepfile_tokens(value)) 

118 

119 

120def _get_restart_contents(filename: str) -> tuple[list[float], list[float]]: 

121 """Parses a ``nep.restart`` file, and returns an unformatted list of the 

122 mean and standard deviation for all model parameters. 

123 Intended to be used by the py:meth:`~Model.read_restart` function. 

124 

125 Parameters 

126 ---------- 

127 filename 

128 input file name 

129 """ 

130 mu = [] # Mean 

131 sigma = [] # Standard deviation 

132 with open(filename) as f: 

133 for k, line in enumerate(f.readlines()): 

134 flds = line.split() 

135 if len(flds) == 0: 

136 raise IOError(f'Empty line number {k}') 

137 if len(flds) == 2: 

138 mu.append(float(flds[0])) 

139 sigma.append(float(flds[1])) 

140 else: 

141 raise IOError(f'Failed to parse line {k} from {filename}') 

142 return mu, sigma 

143 

144 

145def _get_model_type(first_row: list[str]) -> str: 

146 """Parses a the first row of a ``nep.txt`` file, and returns the 

147 type of NEP model. Available types are `potential`, `potential_with_charges`, 

148 `dipole`, and `polarizability`. 

149 

150 Parameters 

151 ---------- 

152 first_row 

153 First row of a NEP file, split by white space. 

154 """ 

155 model_type = first_row[0] 

156 if 'charge' in model_type: 

157 return 'potential_with_charges' 

158 elif 'dipole' in model_type: 

159 return 'dipole' 

160 elif 'polarizability' in model_type: 

161 return 'polarizability' 

162 return 'potential' 

163 

164 

165def _get_charge_mode(model_type_token: str) -> int: 

166 """Parses the charge_mode (0, 1, or 2) from the first token of a ``nep.txt`` 

167 header line, e.g. ``nep4_charge1``, ``nep4_zbl_charge2``. Returns 0 for 

168 non-charge models. 

169 

170 Parameters 

171 ---------- 

172 model_type_token 

173 First token of the first row of a NEP file (``flds[0]``). 

174 """ 

175 match = re.search(r'charge(\d+)', model_type_token) 

176 return int(match.group(1)) if match else 0 

177 

178 

179def _parse_nep_version(token: str) -> int: 

180 """Extracts the integer NEP version from a header token such as ``nep4``.""" 

181 return int(token.replace('nep', '').split('_')[0]) 

182 

183 

184def _require_nep4(version: int) -> None: 

185 """Raises a ValueError unless `version` is 4, the only version calorine supports.""" 

186 if version != 4: 

187 raise ValueError( 

188 f'Unsupported NEP version {version}; only NEP4 models are supported.' 

189 ) 

190 

191 

192def _get_nep_version(filename: str) -> int: 

193 """Reads only the first line of a ``nep.txt`` file and returns the NEP version. 

194 Cheaper than :func:`_get_nep_contents` when only the version is needed. 

195 

196 Parameters 

197 ---------- 

198 filename 

199 input file name 

200 """ 

201 with open(filename) as f: 

202 first_line = f.readline() 

203 flds = first_line.split() 

204 if not flds or not flds[0].startswith('nep'): 

205 raise ValueError(f'Invalid model file: {filename}') 

206 return _parse_nep_version(flds[0]) 

207 

208 

209def _get_nep_contents(filename: str) -> tuple[dict, list[float]]: 

210 """Parses a ``nep.txt`` file, and returns a dict describing the header 

211 and an unformatted list of all model parameters. 

212 Intended to be used by the :func:`read_model <calorine.nep.read_model>` function. 

213 

214 Parameters 

215 ---------- 

216 filename 

217 input file name 

218 """ 

219 # parse file and split header and parameters 

220 header = [] 

221 parameters = [] 

222 nheader = 6 # nep4, cutoff, n_max, basis_size, l_max, ANN, and an optional zbl row 

223 with open(filename) as f: 

224 for k, line in enumerate(f.readlines()): 

225 flds = line.split() 

226 if len(flds) == 0: 

227 raise IOError(f'Empty line number {k}') 

228 if k == 0 and 'zbl' in flds[0]: 

229 nheader += 1 

230 if k < nheader: 

231 header.append(tuple(flds)) 

232 elif len(flds) == 1: 

233 parameters.append(float(flds[0])) 

234 else: 

235 raise IOError(f'Failed to parse line {k} from {filename}') 

236 # compile data from the header into a dict 

237 data = {} 

238 for flds in header: 

239 if flds[0] in ['cutoff', 'zbl']: 

240 data[flds[0]] = tuple(map(float, flds[1:])) 

241 elif flds[0] in ['n_max', 'l_max', 'ANN', 'basis_size']: 

242 data[flds[0]] = tuple(map(int, flds[1:])) 

243 elif flds[0].startswith('nep'): 

244 version = _parse_nep_version(flds[0]) 

245 _require_nep4(version) 

246 data['version'] = version 

247 data['types'] = flds[2:] 

248 data['model_type'] = _get_model_type(flds) 

249 data['charge_mode'] = _get_charge_mode(flds[0]) 

250 else: 

251 raise ValueError(f'Unknown field: {flds[0]}') 

252 return data, parameters 

253 

254 

255def _sort_descriptor_parameters(parameters: list[float], 

256 types: list[str], 

257 n_max_radial: int, 

258 n_basis_radial: int, 

259 n_max_angular: int, 

260 n_basis_angular: int) -> tuple[DescriptorWeights, 

261 DescriptorWeights]: 

262 """Reads a list of descriptors parameters and sorts them into two 

263 appropriately structured `dicts`, one for radial and one for angular descriptor weights. 

264 Intended to be used by the :func:`read_model <calorine.nep.read_model>` function. 

265 """ 

266 # split up descriptor by chemical species and radial/angular 

267 n_types = len(types) 

268 n = len(parameters) // (n_types * n_types) 

269 

270 m = (n_max_radial + 1) * (n_basis_radial + 1) 

271 descriptor_weights = parameters.reshape((n, n_types * n_types)).T 

272 descriptor_weights_radial = descriptor_weights[:, :m] 

273 descriptor_weights_angular = descriptor_weights[:, m:] 

274 

275 # add descriptors to data dict 

276 radial_descriptor_weights = {} 

277 angular_descriptor_weights = {} 

278 m = -1 

279 for i, j in product(range(n_types), repeat=2): 

280 m += 1 

281 s1, s2 = types[i], types[j] 

282 radial_descriptor_weights[(s1, s2)] = descriptor_weights_radial[m, :].reshape( 

283 (n_max_radial + 1, n_basis_radial + 1) 

284 ) 

285 angular_descriptor_weights[(s1, s2)] = descriptor_weights_angular[m, :].reshape( 

286 (n_max_angular + 1, n_basis_angular + 1) 

287 ) 

288 return radial_descriptor_weights, angular_descriptor_weights 

289 

290 

291def _number_of_output_biases(is_model_with_charges: bool) -> int: 

292 """Returns the number of output-layer bias values in a single network pass. 

293 

294 This is the trailing block of a network pass in ``nep.txt`` and ``nep.restart``, 

295 counted per pass rather than per file. A polarizability model runs two passes and 

296 therefore carries twice this many bias values in total, while every other model 

297 type carries exactly this many. 

298 

299 Parameters 

300 ---------- 

301 is_model_with_charges 

302 Whether the model has a charge output head, i.e. whether its type is 

303 ``potential_with_charges``. 

304 

305 Returns 

306 ------- 

307 int 

308 Number of bias values per network pass: 2 for a model with charges, since 

309 ``sqrt_epsilon_infinity`` precedes the global bias, and 1 otherwise. 

310 

311 Example 

312 ------- 

313 >>> from calorine.nep.model import _number_of_output_biases 

314 >>> _number_of_output_biases(False) 

315 1 

316 >>> _number_of_output_biases(True) 

317 2 

318 """ 

319 return 2 if is_model_with_charges else 1 

320 

321 

322def _sort_ann_parameters(parameters: list[float], 

323 ann_groupings: list[str], 

324 n_neuron: int, 

325 n_descriptor: int, 

326 is_polarizability_model: bool, 

327 is_model_with_charges: bool 

328 ) -> NetworkWeights: 

329 """Reads a list of model parameters and sorts them into an appropriately structured `dict`. 

330 Intended to be used by the :func:`read_model <calorine.nep.read_model>` function 

331 and by :meth:`Model.read_restart <calorine.nep.Model.read_restart>`. 

332 """ 

333 # ann_groupings holds one entry per species 

334 n_bias = _number_of_output_biases(is_model_with_charges) 

335 n_ann_input_weights = (n_descriptor + 1) * n_neuron # weights + bias 

336 n_ann_output_weights = 2*n_neuron if is_model_with_charges else n_neuron # only weights 

337 n_ann_parameters = ( 

338 n_ann_input_weights + n_ann_output_weights 

339 ) * len(ann_groupings) + n_bias 

340 

341 # Group ANN parameters 

342 pars = {} 

343 n1 = 0 

344 n_network_params = n_ann_input_weights + n_ann_output_weights # except last bias(es) 

345 

346 n_count = 2 if is_polarizability_model else 1 

347 n_outputs = 2 if is_model_with_charges else 1 

348 for count in range(n_count): 

349 # if polarizability model, all parameters including bias are repeated 

350 # need to offset n1 by +1 to handle bias 

351 n1 += count 

352 for s in ann_groupings: 

353 # Get the parameters for the ANN. There is one network per atomic species. 

354 ann_parameters = parameters[n1 : n1 + n_network_params] 

355 ann_input_weights = ann_parameters[:n_ann_input_weights] 

356 w0 = np.zeros((n_neuron, n_descriptor)) 

357 w0[...] = np.nan 

358 b0 = np.zeros((n_neuron, 1)) 

359 b0[...] = np.nan 

360 for n in range(n_neuron): 

361 for nu in range(n_descriptor): 

362 w0[n, nu] = ann_input_weights[n * n_descriptor + nu] 

363 b0[:, 0] = ann_input_weights[n_neuron * n_descriptor :] 

364 

365 assert np.all( 

366 w0.shape == (n_neuron, n_descriptor) 

367 ), f'w0 has invalid shape for key {s}; please submit a bug report' 

368 assert np.all( 

369 b0.shape == (n_neuron, 1) 

370 ), f'b0 has invalid shape for key {s}; please submit a bug report' 

371 assert not np.any( 

372 np.isnan(w0) 

373 ), f'some weights in w0 are nan for key {s}; please submit a bug report' 

374 assert not np.any( 

375 np.isnan(b0) 

376 ), f'some weights in b0 are nan for key {s}; please submit a bug report' 

377 

378 ann_output_weights = ann_parameters[ 

379 n_ann_input_weights : n_ann_input_weights + n_ann_output_weights 

380 ] 

381 w1 = np.zeros((1, n_neuron * n_outputs)) 

382 w1[0, :] = ann_output_weights[:] 

383 assert np.all( 

384 w1.shape == (1, n_neuron * n_outputs) 

385 ), f'w1 has invalid shape for key {s}; please submit a bug report' 

386 assert not np.any( 

387 np.isnan(w1) 

388 ), f'some weights in w1 are nan for key {s}; please submit a bug report' 

389 

390 if count == 0 and n_outputs == 1: 

391 pars[s] = dict(w0=w0, b0=b0, w1=w1) 

392 elif count == 0 and n_outputs == 2: 

393 pars[s] = dict(w0=w0, b0=b0, w1=w1[0, :n_neuron], w1_charge=w1[0, n_neuron:]) 

394 else: 

395 pars[s].update({'w0_polar': w0, 'b0_polar': b0, 'w1_polar': w1}) 

396 # Jump to bias 

397 n1 += n_network_params 

398 # For a model without charges we only have one (global) bias. 

399 # For a model with charges we have two (global) biases. 

400 if count == 0 and n_outputs == 1: 

401 pars['b1'] = parameters[n1] 

402 elif count == 0 and n_outputs == 2: 

403 pars['sqrt_epsilon_infinity'] = parameters[n1] 

404 pars['b1'] = parameters[n1+1] 

405 else: 

406 pars['b1_polar'] = parameters[n1] 

407 sum = 0 

408 for s in pars.keys(): 

409 if s.startswith('b1') or s.startswith('sqrt'): 

410 sum += 1 

411 else: 

412 sum += np.sum([np.array(p).size for p in pars[s].values()]) 

413 assert sum == n_ann_parameters * n_count, ( 

414 'Inconsistent number of parameters accounted for; please submit a bug report\n' 

415 f'{sum} != {n_ann_parameters}' 

416 ) 

417 return pars 

418 

419 

420def _adaptive_sigma(mu_arr, sigma_factor: float, sigma_floor: float) -> np.ndarray: 

421 """Return adaptive SNES sigma: ``max(sigma_floor, sigma_factor * |mu|)``.""" 

422 return np.maximum(sigma_floor, sigma_factor * np.abs(mu_arr)) 

423 

424 

425def _format_header_float(value: float) -> str: 

426 """Format *value* the way GPUMD writes the float-valued header fields of ``nep.txt``, which 

427 it does with ``%g`` (see ``write_nep_txt`` in ``src/main_nep/fitness.cu``). A cutoff of 6 is 

428 therefore written as ``6`` rather than ``6.0``. 

429 

430 ``%g`` keeps six significant digits, so it is used only where it reproduces the value exactly. 

431 Anything carrying more digits than GPUMD would have written is emitted in full rather than 

432 truncated, which matters for a file that came from somewhere else. 

433 """ 

434 text = f'{float(value):g}' 

435 return text if float(text) == float(value) else repr(float(value)) 

436 

437 

438_RESTART_COMPONENTS = ('network_weights', 'descriptor', 'charge_head') 

439 

440 

441def _restart_leaves(model, restart_params, component=None, species=None): 

442 """Yield ``(mu, sigma_container, sigma_key)`` for every leaf entry of 

443 *restart_params* that matches the requested *component*/*species* filters. 

444 

445 ``sigma_container[sigma_key]`` is either a numpy array or a scalar float; 

446 together with ``mu`` (same shape/type) this is everything 

447 :func:`_apply_sigma_strategy` needs to read and update one leaf. 

448 

449 *component* selects among ``'network_weights'`` (w0, b0, w1, and the global 

450 b1 bias), ``'descriptor'`` (radial/angular descriptor weight pairs), and 

451 ``'charge_head'`` (w1_charge and sqrt_epsilon_infinity). ``None`` means all 

452 three. *species* restricts per-species entries (and descriptor pairs 

453 involving that species) to the given species; global scalars (b1, 

454 sqrt_epsilon_infinity) are only included when *species* is ``None``, since 

455 they are not owned by a single species. 

456 """ 

457 if component is None: 

458 wanted = set(_RESTART_COMPONENTS) 

459 else: 

460 wanted = {component} if isinstance(component, str) else set(component) 

461 unknown = wanted - set(_RESTART_COMPONENTS) 

462 if unknown: 

463 raise ValueError( 

464 f'Unknown component(s) {sorted(unknown)}; expected any of ' 

465 f'{_RESTART_COMPONENTS}' 

466 ) 

467 

468 if species is None: 

469 species_filter = None 

470 else: 

471 species_filter = {species} if isinstance(species, str) else set(species) 

472 

473 keys = model.types 

474 ann_mu, ann_sigma = restart_params['ann_mu'], restart_params['ann_sigma'] 

475 

476 if 'network_weights' in wanted: 

477 for s in keys: 

478 if species_filter is not None and s not in species_filter: 

479 continue 

480 for pname in ('w0', 'b0', 'w1', 'w0_polar', 'b0_polar', 'w1_polar'): 

481 if pname in ann_mu[s]: 

482 yield ann_mu[s][pname], ann_sigma[s], pname 

483 if species_filter is None: 

484 for pname in ('b1', 'b1_polar'): 

485 if pname in ann_mu: 

486 yield ann_mu[pname], ann_sigma, pname 

487 

488 if 'charge_head' in wanted: 

489 for s in keys: 

490 if species_filter is not None and s not in species_filter: 

491 continue 

492 if 'w1_charge' in ann_mu[s]: 

493 yield ann_mu[s]['w1_charge'], ann_sigma[s], 'w1_charge' 

494 if species_filter is None and 'sqrt_epsilon_infinity' in ann_mu: 

495 yield ann_mu['sqrt_epsilon_infinity'], ann_sigma, 'sqrt_epsilon_infinity' 

496 

497 if 'descriptor' in wanted: 

498 for desc_type in ('radial', 'angular'): 

499 mu_dict = restart_params[f'{desc_type}_descriptor_mu'] 

500 sigma_dict = restart_params[f'{desc_type}_descriptor_sigma'] 

501 for pair, mu_val in mu_dict.items(): 

502 if species_filter is not None and not (species_filter & set(pair)): 

503 continue 

504 yield mu_val, sigma_dict, pair 

505 

506 

507_CHARGE_HEAD_PARAMETERS = ('w1_charge', 'sqrt_epsilon_infinity') 

508 

509 

510def _parameter_component(name: str) -> str: 

511 """Return the restart component that the ANN parameter head *name* belongs to. 

512 

513 Mirrors the grouping that :func:`_restart_leaves` applies to a loaded restart, for code 

514 that walks the parameters of the model instead. 

515 """ 

516 base = name[:-len('_polar')] if name.endswith('_polar') else name 

517 if base in _CHARGE_HEAD_PARAMETERS: 

518 return 'charge_head' 

519 return 'network_weights' 

520 

521 

522# The axis along which each per-species head runs over the neurons of the hidden layer. 

523# The second network of a polarizability model has the same heads with the suffix '_polar'. 

524_NEURON_AXIS = {'w0': 0, 'b0': 0, 'w1': -1, 'w1_charge': -1} 

525 

526 

527def _split_network_suffix(name: str) -> tuple[str, str]: 

528 """Return the head and the network suffix of the per-species parameter *name*.""" 

529 head = name.removesuffix('_polar') 

530 return head, name[len(head):] 

531 

532 

533def _append_filled(array: np.ndarray, count: int, axis: int, fill: float) -> np.ndarray: 

534 """Return *array* with *count* entries of value *fill* appended along *axis*.""" 

535 shape = list(array.shape) 

536 shape[axis] = count 

537 return np.concatenate([array, np.full(shape, fill)], axis=axis) 

538 

539 

540def _leaf_get(container, key): 

541 """Read a parameter leaf from *container*, which is either a dict or the model itself.""" 

542 return container[key] if isinstance(container, dict) else getattr(container, key) 

543 

544 

545def _model_parameter_leaves(model, component=None, species=None): 

546 """Yield ``(container, key, species, name)`` for every parameter of *model*, so that the 

547 leaf can be read via :func:`_leaf_get`. 

548 

549 This is the counterpart of :func:`_restart_leaves` for the parameters of the model 

550 itself. *species* is the species that owns the leaf, or ``None`` for the global scalars 

551 and the descriptor pairs, and *name* is the name of the parameter head as used in 

552 ``restart_parameters``, so that both can be used to look up the matching restart entry. 

553 

554 The *component*/*species* filters work exactly as in :func:`_restart_leaves`: global 

555 scalars are only included when *species* is ``None``, and a descriptor pair is included 

556 when either of its two species is selected. 

557 """ 

558 if component is None: 

559 wanted = set(_RESTART_COMPONENTS) 

560 else: 

561 wanted = {component} if isinstance(component, str) else set(component) 

562 unknown = wanted - set(_RESTART_COMPONENTS) 

563 if unknown: 

564 raise ValueError( 

565 f'Unknown component(s) {sorted(unknown)}; expected any of ' 

566 f'{_RESTART_COMPONENTS}' 

567 ) 

568 

569 if species is None: 

570 species_filter = None 

571 else: 

572 species_filter = {species} if isinstance(species, str) else set(species) 

573 

574 keys = model.types 

575 

576 for s in keys: 

577 if species_filter is not None and s not in species_filter: 

578 continue 

579 for name in model.ann_parameters[s]: 

580 if _parameter_component(name) in wanted: 

581 yield model.ann_parameters[s], name, s, name 

582 

583 if species_filter is None: 

584 for name in model.ann_parameters: 

585 if name in keys: 

586 continue # per-species dictionaries, handled above 

587 if _parameter_component(name) in wanted: 

588 yield model.ann_parameters, name, None, name 

589 if model.sqrt_epsilon_infinity is not None and 'charge_head' in wanted: 

590 yield model, 'sqrt_epsilon_infinity', None, 'sqrt_epsilon_infinity' 

591 

592 if 'descriptor' in wanted: 

593 for descriptor_type in ('radial', 'angular'): 

594 weights = getattr(model, f'{descriptor_type}_descriptor_weights') 

595 for pair in weights: 

596 if species_filter is not None and not (species_filter & set(pair)): 

597 continue 

598 yield weights, pair, None, f'{descriptor_type}_descriptor' 

599 

600 

601def _apply_sigma_strategy(mu, sigma_container, sigma_key, strategy, target, rng, **kw): 

602 """Update ``sigma_container[sigma_key]`` in place at the positions selected 

603 by *target* (``'unset'`` -> NaN entries, ``'set'`` -> non-NaN entries, 

604 ``'all'`` -> everything), using *mu* and *strategy* to compute new values. 

605 

606 :meth:`Model.set_restart_sigma` validates *strategy* before delegating, so the last arm of 

607 the dispatch takes every value that reaches here other than the four named ahead of it. 

608 """ 

609 sigma = sigma_container[sigma_key] 

610 if np.isscalar(sigma) or isinstance(sigma, (float, int)): 

611 current = float(sigma) 

612 is_unset = np.isnan(current) 

613 apply_here = ( 

614 target == 'all' or (target == 'unset' and is_unset) 

615 or (target == 'set' and not is_unset) 

616 ) 

617 if not apply_here: 

618 return 

619 mu_val = float(mu) 

620 if strategy == 'constant': 

621 new_val = kw['value'] 

622 elif strategy == 'scale_mu': 

623 new_val = float(_adaptive_sigma(np.array(mu_val), kw['factor'], kw['floor'])) 

624 elif strategy == 'scale_sigma': 

625 if is_unset: 

626 raise ValueError( 

627 f"strategy='scale_sigma' requires an existing sigma value, but " 

628 f'{sigma_key!r} is unset (NaN); set it first, e.g. with ' 

629 "target='unset'." 

630 ) 

631 new_val = current * kw['factor'] 

632 elif strategy == 'uniform': 

633 new_val = float(rng.uniform(kw['low'], kw['high'])) 

634 else: 

635 new_val = float(abs(rng.normal(kw['mean'], kw['std']))) 

636 sigma_container[sigma_key] = float(new_val) 

637 return 

638 

639 sigma_arr = sigma_container[sigma_key] 

640 mu_arr = np.asarray(mu, dtype=float) 

641 if target == 'unset': 

642 mask = np.isnan(sigma_arr) 

643 elif target == 'set': 

644 mask = ~np.isnan(sigma_arr) 

645 else: 

646 mask = np.ones_like(sigma_arr, dtype=bool) 

647 if not np.any(mask): 

648 return 

649 

650 if strategy == 'constant': 

651 sigma_arr[mask] = kw['value'] 

652 elif strategy == 'scale_mu': 

653 sigma_arr[mask] = _adaptive_sigma(mu_arr[mask], kw['factor'], kw['floor']) 

654 elif strategy == 'scale_sigma': 

655 if np.any(np.isnan(sigma_arr[mask])): 

656 raise ValueError( 

657 f"strategy='scale_sigma' requires existing sigma values, but " 

658 f'{sigma_key!r} has unset (NaN) entries within the selected ' 

659 "target; set them first, e.g. with target='unset'." 

660 ) 

661 sigma_arr[mask] = sigma_arr[mask] * kw['factor'] 

662 elif strategy == 'uniform': 

663 sigma_arr[mask] = rng.uniform(kw['low'], kw['high'], size=int(np.sum(mask))) 

664 else: 

665 sigma_arr[mask] = np.abs(rng.normal(kw['mean'], kw['std'], size=int(np.sum(mask)))) 

666 

667 

668def _draw_values(strategy, rng, size=None, **kw): 

669 """Return parameter values drawn according to *strategy*: a single float if *size* is 

670 ``None``, otherwise an array of *size* values. 

671 

672 Unlike the sigma strategies of :func:`_apply_sigma_strategy`, the drawn values are not 

673 forced to be positive, since a parameter value may have either sign. 

674 

675 :meth:`Model.initialize_parameters` validates *strategy* before delegating, so the last arm 

676 of the dispatch takes every value that reaches here other than the two named ahead of it. 

677 """ 

678 if strategy == 'constant': 

679 return float(kw['value']) if size is None else np.full(size, float(kw['value'])) 

680 if strategy == 'uniform': 

681 return (float(rng.uniform(kw['low'], kw['high'])) if size is None 

682 else rng.uniform(kw['low'], kw['high'], size=size)) 

683 return (float(rng.normal(kw['mean'], kw['std'])) if size is None 

684 else rng.normal(kw['mean'], kw['std'], size=size)) 

685 

686 

687def _restart_leaf_for(restart_parameters, species, name, key): 

688 """Return ``(mu_container, sigma_container, restart_key)`` for the restart entry that 

689 corresponds to the model parameter leaf described by *species*, *name*, and *key*, or 

690 ``None`` if the restart has no counterpart for it. 

691 """ 

692 if name.endswith('_descriptor'): 

693 mu_container = restart_parameters[f'{name}_mu'] 

694 sigma_container = restart_parameters[f'{name}_sigma'] 

695 restart_key = key 

696 elif species is None: 

697 mu_container = restart_parameters['ann_mu'] 

698 sigma_container = restart_parameters['ann_sigma'] 

699 restart_key = name 

700 else: 

701 mu_container = restart_parameters['ann_mu'][species] 

702 sigma_container = restart_parameters['ann_sigma'][species] 

703 restart_key = name 

704 if restart_key not in sigma_container or restart_key not in mu_container: 

705 # A parameter head that the restart tree in hand does not carry. Whether such a 

706 # parameter is new cannot be told from a sigma, so it is left alone. Every head of 

707 # every shipped model has a counterpart, so this guards against a tree assembled 

708 # by other means rather than against any model type in particular. 

709 return None 

710 return mu_container, sigma_container, restart_key 

711 

712 

713def _restart_sigma_counts(model, component=None) -> dict[str, int]: 

714 """Return the number of restart sigma entries of *model* as a dict with the keys 

715 ``'total'``, ``'frozen'`` (sigma of exactly zero) and ``'unset'`` (sigma of ``NaN``), 

716 restricted to *component* if given. 

717 

718 The traversal goes through :func:`_restart_leaves`, so every parameter head 

719 registered there is covered automatically. Scalar leaves (``b1``, 

720 ``sqrt_epsilon_infinity``) count as one entry each. 

721 """ 

722 total, frozen, unset = 0, 0, 0 

723 for _, sigma_container, sigma_key in _restart_leaves( 

724 model, model.restart_parameters, component 

725 ): 

726 sigma = np.asarray(sigma_container[sigma_key], dtype=float) 

727 total += int(sigma.size) 

728 frozen += int(np.count_nonzero(sigma == 0.0)) 

729 unset += int(np.count_nonzero(np.isnan(sigma))) 

730 return {'total': total, 'frozen': frozen, 'unset': unset} 

731 

732 

733def _model_parameter_counts(model) -> dict[str, dict[str, int]]: 

734 """Return ``{component: {'total': n}}`` for the current parameters of *model*. 

735 

736 Used when no restart is loaded, so there are no sigma values to split into frozen and 

737 unset entries. The traversal goes through :func:`_model_parameter_leaves`, which also 

738 drives :meth:`Model.initialize_parameters`, so the two cannot disagree on which 

739 component a parameter head belongs to. The ``q_scaler`` entries are excluded, since they 

740 are not fit parameters and have no restart counterpart. 

741 """ 

742 counts = {} 

743 for component in _RESTART_COMPONENTS: 

744 total = sum(int(np.asarray(_leaf_get(container, key)).size) 

745 for container, key, _, _ in _model_parameter_leaves(model, component)) 

746 if total > 0: 

747 counts[component] = {'total': total} 

748 return counts 

749 

750 

751def _new_restart_parameters_from_model(model) -> RestartParameters: 

752 """Build a fresh restart-parameters dict from a model's current (trained) 

753 parameters: ``mu`` is copied from the model, ``sigma`` is set to ``NaN`` 

754 everywhere (unset), to be filled in via :meth:`Model.set_restart_sigma`. 

755 """ 

756 keys = model.types 

757 suffixes = ['', '_polar'] if model.model_type == 'polarizability' else [''] 

758 

759 ann_mu, ann_sigma = {}, {} 

760 for s in keys: 

761 params = model.ann_parameters[s] 

762 mu_entry, sigma_entry = {}, {} 

763 for suffix in suffixes: 

764 for base in ('w0', 'b0', 'w1', 'w1_charge'): 

765 pname = f'{base}{suffix}' 

766 if pname in params: 

767 arr = np.array(params[pname], dtype=float) 

768 mu_entry[pname] = arr.copy() 

769 sigma_entry[pname] = np.full(arr.shape, np.nan) 

770 ann_mu[s] = mu_entry 

771 ann_sigma[s] = sigma_entry 

772 

773 for suffix in suffixes: 

774 b1_key = f'b1{suffix}' 

775 ann_mu[b1_key] = float(model.ann_parameters[b1_key]) 

776 ann_sigma[b1_key] = float('nan') 

777 if model.sqrt_epsilon_infinity is not None: 

778 ann_mu['sqrt_epsilon_infinity'] = float(model.sqrt_epsilon_infinity) 

779 ann_sigma['sqrt_epsilon_infinity'] = float('nan') 

780 

781 radial_mu = { 

782 k: np.array(v, dtype=float).copy() for k, v in model.radial_descriptor_weights.items() 

783 } 

784 radial_sigma = {k: np.full(v.shape, np.nan) for k, v in radial_mu.items()} 

785 angular_mu = { 

786 k: np.array(v, dtype=float).copy() for k, v in model.angular_descriptor_weights.items() 

787 } 

788 angular_sigma = {k: np.full(v.shape, np.nan) for k, v in angular_mu.items()} 

789 

790 return { 

791 'ann_mu': ann_mu, 

792 'ann_sigma': ann_sigma, 

793 'radial_descriptor_mu': radial_mu, 

794 'radial_descriptor_sigma': radial_sigma, 

795 'angular_descriptor_mu': angular_mu, 

796 'angular_descriptor_sigma': angular_sigma, 

797 } 

798 

799 

800def _recalculate_parameter_counts(new) -> None: 

801 """Recompute n_ann_parameters, n_descriptor_parameters, and n_parameters on *new*. 

802 

803 Reads all architectural state from *new* directly, so callers must update 

804 new.n_neuron, new.n_descriptor_radial/angular, new.model_type, and new.types 

805 before calling this function. 

806 """ 

807 n_types = len(new.types) 

808 n_desc = new.n_descriptor_radial + new.n_descriptor_angular 

809 is_charged = new.model_type == 'potential_with_charges' 

810 n_networks = n_types 

811 n_output_biases = _number_of_output_biases(is_charged) 

812 n_ann_input_weights = (n_desc + 1) * new.n_neuron 

813 n_ann_output_weights = 2 * new.n_neuron if is_charged else new.n_neuron 

814 new.n_ann_parameters = ( 

815 n_ann_input_weights + n_ann_output_weights 

816 ) * n_networks + n_output_biases 

817 new.n_descriptor_parameters = n_types ** 2 * ( 

818 (new.n_max_radial + 1) * (new.n_basis_radial + 1) 

819 + (new.n_max_angular + 1) * (new.n_basis_angular + 1) 

820 ) 

821 new.n_parameters = new.n_ann_parameters + new.n_descriptor_parameters + n_desc 

822 if new.model_type == 'polarizability': 

823 new.n_parameters += new.n_ann_parameters 

824 

825 

826@dataclass 

827class Model: 

828 r"""Objects of this class represent a NEP model in a form suitable for 

829 inspection and manipulation. Typically a :class:`Model` object is instantiated 

830 by calling the :func:`read_model <calorine.nep.read_model>` function. 

831 

832 Attributes 

833 ---------- 

834 version : int 

835 NEP version. Currently the only supported version is NEP4. 

836 model_type: str 

837 One of ``potential``, ``dipole`` or ``polarizability``. 

838 types : tuple[str, ...] 

839 Chemical species that this model represents. 

840 radial_cutoff : float | list[float] 

841 The radial cutoff parameter in Å. 

842 Is a list of radial cutoffs ordered after ``types`` in the case of typewise cutoffs. 

843 angular_cutoff : float | list[float] 

844 The angular cutoff parameter in Å. 

845 Is a list of angular cutoffs ordered after ``types`` in the case of typewise cutoffs. 

846 max_neighbors_radial : int 

847 Maximum number of neighbors in neighbor list for radial terms. 

848 max_neighbors_angular : int 

849 Maximum number of neighbors in neighbor list for angular terms. 

850 zbl : tuple[float, float] 

851 Inner and outer cutoff for transition to ZBL potential. 

852 zbl_typewise_cutoff_factor : float 

853 Optional typewise cutoff factor for the ZBL potential, corresponding to an 

854 optional third value on the ``zbl`` line in ``nep.txt`` when 

855 ``use_typewise_cutoff_zbl`` is enabled during training. ``None`` if not set. 

856 n_basis_radial : int 

857 Number of radial basis functions :math:`n_\mathrm{basis}^\mathrm{R}`. 

858 n_basis_angular : int 

859 Number of angular basis functions :math:`n_\mathrm{basis}^\mathrm{A}`. 

860 n_max_radial : int 

861 Maximum order of Chebyshev polymonials included in 

862 radial expansion :math:`n_\mathrm{max}^\mathrm{R}`. 

863 n_max_angular : int 

864 Maximum order of Chebyshev polymonials included in 

865 angular expansion :math:`n_\mathrm{max}^\mathrm{A}`. 

866 l_max_3b : int 

867 Maximum expansion order for three-body terms :math:`l_\mathrm{max}^\mathrm{3b}`. 

868 l_max_4b : int 

869 2 if the four-body terms are included and 0 otherwise. 

870 l_max_5b : int 

871 Maximum expansion order for five-body terms :math:`l_\mathrm{max}^\mathrm{5b}`. 

872 has_q_112 : int 

873 Flag enabling the 5-body :math:`q_{112}` descriptor (0 or 1). 

874 has_q_123 : int 

875 Flag enabling the 5-body :math:`q_{123}` descriptor (0 or 1). 

876 has_q_233 : int 

877 Flag enabling the 5-body :math:`q_{233}` descriptor (0 or 1). 

878 has_q_134 : int 

879 Flag enabling the higher-body :math:`q_{134}` descriptor (0 or 1). 

880 n_descriptor_radial : int 

881 Dimension of radial part of descriptor. 

882 n_descriptor_angular : int 

883 Dimension of angular part of descriptor. 

884 n_neuron : int 

885 Number of neurons in hidden layer. 

886 n_parameters : int 

887 Total number of parameters including scalers (which are not fit parameters). 

888 n_descriptor_parameters : int 

889 Number of parameters in descriptor. 

890 n_ann_parameters : int 

891 Number of neural network weights. 

892 ann_parameters : dict[tuple[str, dict[str, np.darray]]] 

893 Neural network weights. 

894 q_scaler : list[float] 

895 Scaling parameters. 

896 radial_descriptor_weights : dict[tuple[str, str], np.ndarray] 

897 Radial descriptor weights by combination of species; the array for each combination 

898 has dimensions of 

899 :math:`(n_\mathrm{max}^\mathrm{R}+1) \times (n_\mathrm{basis}^\mathrm{R}+1)`. 

900 angular_descriptor_weights : dict[tuple[str, str], np.ndarray] 

901 Angular descriptor weights by combination of species; the array for each combination 

902 has dimensions of 

903 :math:`(n_\mathrm{max}^\mathrm{A}+1) \times (n_\mathrm{basis}^\mathrm{A}+1)`. 

904 sqrt_epsilon_infinity : float | None 

905 Square root of epsilon infinity $\epsilon_\infty$ (only for NEP models with charges). 

906 charge_mode : int 

907 Charge algorithm variant for ``potential_with_charges`` models; 0 for 

908 non-charge-aware models. 1 corresponds to a qNEP model including both real- and 

909 reciprocal-space contributions. 2 corresponds to a qNEP model, including the 

910 reciprocal-space contribution only. 

911 restart_parameters : dict[str, dict[str, dict[str, np.ndarray]]] 

912 NEP restart parameters. A nested dictionary that contains the mean (mu) and standard 

913 deviation (sigma) for the ANN and descriptor parameters. Is set using the 

914 py:meth:`~Model.read_restart` method. Defaults to None. 

915 The state of the sigma values is summarized by the 

916 :attr:`~Model.n_frozen_parameters` and :attr:`~Model.n_unset_parameters` 

917 properties, and broken down per category by :attr:`~Model.parameter_counts`. 

918 """ 

919 

920 version: int 

921 model_type: str 

922 types: tuple[str, ...] 

923 

924 radial_cutoff: float | list[float] 

925 angular_cutoff: float | list[float] 

926 

927 n_basis_radial: int 

928 n_basis_angular: int 

929 n_max_radial: int 

930 n_max_angular: int 

931 l_max_3b: int 

932 l_max_4b: int 

933 l_max_5b: int 

934 has_q_112: int 

935 has_q_123: int 

936 has_q_233: int 

937 has_q_134: int 

938 n_descriptor_radial: int 

939 n_descriptor_angular: int 

940 

941 n_neuron: int 

942 n_parameters: int 

943 n_descriptor_parameters: int 

944 n_ann_parameters: int 

945 ann_parameters: NetworkWeights 

946 q_scaler: list[float] 

947 radial_descriptor_weights: DescriptorWeights 

948 angular_descriptor_weights: DescriptorWeights 

949 sqrt_epsilon_infinity: float = None 

950 charge_mode: int = 0 

951 restart_parameters: RestartParameters = None 

952 

953 zbl: tuple[float, float] = None 

954 zbl_typewise_cutoff_factor: float = None 

955 max_neighbors_radial: int = None 

956 max_neighbors_angular: int = None 

957 

958 _special_fields = [ 

959 'ann_parameters', 

960 'q_scaler', 

961 'radial_descriptor_weights', 

962 'angular_descriptor_weights', 

963 ] 

964 

965 def __str__(self) -> str: 

966 s = [] 

967 for fld in self.__dataclass_fields__: 

968 if fld not in self._special_fields: 

969 value = getattr(self, fld) 

970 if fld == 'restart_parameters': 

971 value = self._restart_availability() 

972 s += [f'{fld:22} : {value}'] 

973 return '\n'.join(s) 

974 

975 def _repr_html_(self) -> str: 

976 s = [] 

977 s += ['<table border="1" class="dataframe"'] 

978 s += [ 

979 '<thead><tr><th style="text-align: left;">Field</th><th>Value</th></tr></thead>' 

980 ] 

981 s += ['<tbody>'] 

982 for fld in self.__dataclass_fields__: 

983 if fld not in self._special_fields: 

984 value = getattr(self, fld) 

985 if fld == 'restart_parameters': 

986 value = self._restart_availability() 

987 s += [ 

988 f'<tr><td style="text-align: left;">{fld:22}</td>' 

989 f'<td>{value}</td><tr>' 

990 ] 

991 for fld in self._special_fields: 

992 d = getattr(self, fld) 

993 # print('xxx', fld, d) 

994 if fld.endswith('descriptor_weights'): 

995 dim = list(d.values())[0].shape 

996 elif fld == 'ann_parameters': 

997 dim = (len(self.types), len(list(d.values())[0])) 

998 else: 

999 dim = len(d) 

1000 s += [ 

1001 f'<tr><td style="text-align: left;">Dimension of {fld:22}</td><td>{dim}</td><tr>' 

1002 ] 

1003 s += ['</tbody>'] 

1004 s += ['</table>'] 

1005 return ''.join(s) 

1006 

1007 @property 

1008 def training_parameters(self) -> dict: 

1009 """Return the model parameters in the format accepted by :func:`write_nepfile 

1010 <calorine.nep.write_nepfile>`. 

1011 

1012 The result covers every ``nep.in`` keyword that describes the model itself, i.e. the 

1013 keywords that the ``nep`` executable checks against the ``nep.txt`` header before 

1014 training. It carries no training parameters (``lambda_*``, ``generation``, ``batch``, 

1015 and the like). Use :meth:`write_nepfile` to combine the two and write the file, rather 

1016 than merging the dictionaries by hand. 

1017 

1018 Returns 

1019 ------- 

1020 dict 

1021 Keys ``version``, ``model_type``, ``type``, ``cutoff``, ``n_max``, ``basis_size``, 

1022 ``l_max`` and ``neuron``, plus ``zbl`` and ``use_typewise_cutoff_zbl`` for a model 

1023 with ZBL repulsion, and ``charge_mode`` for a charge-aware model. ``zbl`` is the 

1024 single outer cutoff value that the ``nep.in`` ``zbl`` keyword expects (the inner 

1025 cutoff is always half of it), not the ``(inner, outer)`` pair stored in 

1026 :attr:`zbl`. 

1027 

1028 Raises 

1029 ------ 

1030 ValueError 

1031 If :attr:`model_type` is not one of the known model types. 

1032 

1033 """ 

1034 l_max = [self.l_max_3b, self.l_max_4b, self.l_max_5b, 

1035 self.has_q_112, self.has_q_123, self.has_q_233, self.has_q_134] 

1036 # `nep` includes the four-body term by default, so a zero there has to be written 

1037 while len(l_max) > 2 and l_max[-1] == 0: 

1038 l_max = l_max[:-1] 

1039 

1040 if isinstance(self.radial_cutoff, list): 

1041 cutoff = [] 

1042 for rc, ac in zip(self.radial_cutoff, self.angular_cutoff): 

1043 cutoff += [rc, ac] 

1044 else: 

1045 cutoff = [self.radial_cutoff, self.angular_cutoff] 

1046 

1047 if self.model_type not in _MODEL_TYPE_TO_INT: 

1048 raise ValueError(f'Unknown model_type: {self.model_type}') 

1049 

1050 # `type` must precede `cutoff`, which the `nep` executable sizes by the number of 

1051 # types, so the insertion order below is part of the contract. 

1052 params = { 

1053 'version': self.version, 

1054 'model_type': _MODEL_TYPE_TO_INT[self.model_type], 

1055 'type': [len(self.types)] + list(self.types), 

1056 'cutoff': cutoff, 

1057 'n_max': [self.n_max_radial, self.n_max_angular], 

1058 'basis_size': [self.n_basis_radial, self.n_basis_angular], 

1059 'l_max': l_max, 

1060 'neuron': self.n_neuron, 

1061 } 

1062 if self.zbl is not None: 

1063 zbl_inner, zbl_outer = self.zbl 

1064 if zbl_inner == 0 and zbl_outer == 0: 

1065 # GPUMD writes `zbl 0 0` for a flexible ZBL potential, which is requested by 

1066 # placing a `zbl.in` file next to `nep.in` rather than through a keyword. 

1067 warn('This model uses a flexible ZBL potential, which cannot be expressed in ' 

1068 'nep.in. Place the zbl.in file of the model next to nep.in and set the ' 

1069 'zbl cutoff by hand.') 

1070 elif abs(zbl_inner - 0.5 * zbl_outer) > 1e-6 * abs(zbl_outer): 

1071 warn(f'The ZBL cutoffs of this model, {zbl_inner} and {zbl_outer} Å, are not ' 

1072 'expressible in nep.in, where the inner cutoff is always half of the ' 

1073 f'outer one. Writing zbl {zbl_outer}, which implies an inner cutoff of ' 

1074 f'{0.5 * zbl_outer} Å.') 

1075 params['zbl'] = zbl_outer 

1076 if self.zbl_typewise_cutoff_factor is not None: 

1077 params['use_typewise_cutoff_zbl'] = self.zbl_typewise_cutoff_factor 

1078 if self.charge_mode != 0: 

1079 params['charge_mode'] = self.charge_mode 

1080 return params 

1081 

1082 def write_nepfile(self, filename: str, parameters: dict = None) -> None: 

1083 """Writes a ``nep.in`` file for this model. 

1084 

1085 The keywords that describe the model are taken from the model itself, via 

1086 :attr:`training_parameters`, so the resulting file is consistent with the ``nep.txt`` 

1087 file written by :meth:`write`. This is the intended way to prepare the input for 

1088 training a model that :meth:`augment`, :meth:`add_species`, :meth:`remove_species`, 

1089 :meth:`keep_species` or :meth:`prune` has changed the architecture of. 

1090 

1091 Training parameters can be supplied via :attr:`parameters`, typically read from an 

1092 existing ``nep.in`` file with :func:`read_nepfile <calorine.nep.read_nepfile>`. Any 

1093 keyword in :attr:`parameters` that describes the model is discarded in favor of the 

1094 value of the model, with a warning naming what was dropped, since such a value refers 

1095 to whichever model that file was written for and not to this one. 

1096 

1097 Parameters 

1098 ---------- 

1099 filename 

1100 Name of the file to write, conventionally ``nep.in``. 

1101 The parent directory must exist. 

1102 parameters 

1103 Training parameters to include, such as ``generation``, ``batch`` and 

1104 ``lambda_e``. Keywords that describe the model are ignored. 

1105 

1106 Raises 

1107 ------ 

1108 ValueError 

1109 If :attr:`model_type` is not one of the known model types. 

1110 

1111 Example 

1112 ------- 

1113 Add a species to a model and write the input files needed to continue training it:: 

1114 

1115 >>> # xdoctest: +SKIP 

1116 >>> from pathlib import Path 

1117 >>> from calorine.nep import read_model, read_nepfile 

1118 >>> model = read_model('nep.txt', restart_file='nep.restart') 

1119 >>> extended = model.add_species(['Cl'], seed=42) 

1120 >>> parameters = read_nepfile('nep.in') 

1121 >>> Path('new').mkdir() 

1122 >>> extended.write('new/nep.txt', restart_file='new/nep.restart') 

1123 >>> extended.write_nepfile('new/nep.in', parameters) 

1124 

1125 """ 

1126 model_parameters = self.training_parameters 

1127 merged = dict(model_parameters) 

1128 discarded = [] 

1129 for key, value in (parameters or {}).items(): 

1130 if key not in _MODEL_PARAMETERS: 

1131 merged[key] = value 

1132 continue 

1133 model_value = model_parameters.get(key) 

1134 if key in ('model_type', 'mode'): 

1135 # `nep` accepts the model type only under the keyword `model_type` 

1136 model_value = model_parameters['model_type'] 

1137 elif key == 'use_typewise_cutoff_zbl' and _nepfile_tokens(value) == []: 

1138 # the bare keyword requests the default factor 

1139 value = _TYPEWISE_CUTOFF_ZBL_FACTOR_DEFAULT 

1140 elif key == 'charge_mode': 

1141 # `charge_mode <mode> [flip_charge]`, where flip_charge configures the 

1142 # training run rather than the model and is therefore kept 

1143 tokens = _nepfile_tokens(value) 

1144 if len(tokens) > 1 and model_value is not None: 

1145 merged['charge_mode'] = [model_value] + tokens[1:] 

1146 value = tokens[:1] 

1147 if not _same_nepfile_value(value, model_value): 

1148 discarded.append((key, value, model_value)) 

1149 if discarded: 

1150 lines = [] 

1151 for key, value, model_value in discarded: 

1152 supplied = _format_nepfile_value(value) 

1153 if model_value is None: 

1154 lines.append(f' {key}: {supplied} -> dropped, this model has no {key}') 

1155 else: 

1156 lines.append(f' {key}: {supplied} -> {_format_nepfile_value(model_value)}') 

1157 warn('The following nep.in parameters were overridden by the model:\n' 

1158 + '\n'.join(lines)) 

1159 _write_nepfile(filename, merged) 

1160 

1161 @property 

1162 def n_frozen_parameters(self) -> int | None: 

1163 """Number of frozen restart parameters, i.e. parameters whose SNES sigma is 

1164 exactly zero and which are therefore excluded from the search during a restart. 

1165 

1166 Returns 

1167 ------- 

1168 int or None 

1169 Number of parameters with a sigma of zero, or ``None`` if 

1170 ``restart_parameters`` is not loaded. 

1171 

1172 Example 

1173 ------- 

1174 Freeze everything that has already been trained and check the result:: 

1175 

1176 >>> # xdoctest: +SKIP 

1177 >>> model = read_model('nep4_PbTe.txt', restart_file='nep4_PbTe.restart') 

1178 >>> frozen = model.set_restart_sigma(strategy='constant', value=0.0, 

1179 ... target='set') 

1180 >>> frozen.n_frozen_parameters 

1181 2281 

1182 """ 

1183 if self.restart_parameters is None: 

1184 return None 

1185 return _restart_sigma_counts(self)['frozen'] 

1186 

1187 @property 

1188 def n_unset_parameters(self) -> int | None: 

1189 """Number of restart parameters whose SNES sigma is unset (``NaN``), i.e. 

1190 parameters created by :meth:`augment` or :meth:`add_species` that have not yet 

1191 been given a search width by :meth:`set_restart_sigma`. 

1192 

1193 :meth:`write_restart` refuses to write while this count is non-zero. 

1194 

1195 Returns 

1196 ------- 

1197 int or None 

1198 Number of parameters with an unset sigma, or ``None`` if 

1199 ``restart_parameters`` is not loaded. 

1200 

1201 Example 

1202 ------- 

1203 Check how many parameters an architecture change created:: 

1204 

1205 >>> # xdoctest: +SKIP 

1206 >>> model = read_model('nep4_PbTe.txt', restart_file='nep4_PbTe.restart') 

1207 >>> model.n_unset_parameters 

1208 0 

1209 >>> model.augment(n_neuron=40).n_unset_parameters 

1210 640 

1211 """ 

1212 if self.restart_parameters is None: 

1213 return None 

1214 return _restart_sigma_counts(self)['unset'] 

1215 

1216 @property 

1217 def parameter_counts(self) -> dict[str, dict[str, int]]: 

1218 """Number of parameters per category, i.e. per component of 

1219 :attr:`restart_parameters`. 

1220 

1221 The categories are the same ones that the ``component`` argument of 

1222 :meth:`set_restart_sigma` accepts, so this shows how many parameters a 

1223 component-restricted call reaches. Only the categories that the model actually 

1224 has are included: ``'charge_head'`` is absent for models without charges. 

1225 

1226 With ``restart_parameters`` loaded, the counts describe the entries of the restart 

1227 and are split into frozen and unset ones. Without it, they describe the current 

1228 parameters of the model and only the totals are available, since there are no sigma 

1229 values to split on. The totals exclude the :attr:`q_scaler` entries, which are not 

1230 fit parameters and have no restart counterpart, so they sum to 

1231 :attr:`n_parameters` minus the length of :attr:`q_scaler`. 

1232 

1233 Returns 

1234 ------- 

1235 dict 

1236 Dictionary keyed by category. Each value holds the number of entries in total 

1237 (``'total'``) and, if ``restart_parameters`` is loaded, the number that are 

1238 frozen (``'frozen'``, sigma of zero) and the number that are unset 

1239 (``'unset'``, sigma of ``NaN``). 

1240 

1241 Example 

1242 ------- 

1243 For a plain model only the totals are reported:: 

1244 

1245 >>> # xdoctest: +SKIP 

1246 >>> read_model('nep4_PbTe.txt').parameter_counts 

1247 {'network_weights': {'total': 1921}, 'descriptor': {'total': 360}} 

1248 

1249 With a restart loaded, the sigma values split each category further. Freeze the 

1250 descriptor and check which category the frozen parameters are in:: 

1251 

1252 >>> model = read_model('nep4_PbTe.txt', restart_file='nep4_PbTe.restart') 

1253 >>> frozen = model.set_restart_sigma(strategy='constant', value=0.0, 

1254 ... component='descriptor', target='all') 

1255 >>> frozen.parameter_counts 

1256 {'network_weights': {'total': 1921, 'frozen': 0, 'unset': 0}, 

1257 'descriptor': {'total': 360, 'frozen': 360, 'unset': 0}} 

1258 """ 

1259 if self.restart_parameters is None: 

1260 return _model_parameter_counts(self) 

1261 counts = {} 

1262 for component in _RESTART_COMPONENTS: 

1263 component_counts = _restart_sigma_counts(self, component) 

1264 if component_counts['total'] > 0: 

1265 counts[component] = component_counts 

1266 return counts 

1267 

1268 def _restart_availability(self) -> str: 

1269 """Return the one-line summary of the restart-parameter state used by 

1270 :meth:`__str__` and :meth:`_repr_html_`.""" 

1271 if self.restart_parameters is None: 

1272 return 'not available' 

1273 return (f'available ({self.n_frozen_parameters} frozen, ' 

1274 f'{self.n_unset_parameters} unset)') 

1275 

1276 def remove_species(self, species: list[str]) -> 'Model': 

1277 """Remove one or more species from the model. 

1278 

1279 Returns a new :class:`Model` with the specified species removed. 

1280 The source model is not modified. 

1281 

1282 If ``restart_parameters`` are loaded, they are pruned to match (the 

1283 entries for the removed species/pairs are dropped); the surviving 

1284 entries are left exactly as they were. Use :meth:`set_restart_sigma` 

1285 explicitly afterwards if you want to re-open the SNES search width for 

1286 the surviving parameters before continuing training. 

1287 

1288 Parameters 

1289 ---------- 

1290 species 

1291 Species names to remove. 

1292 

1293 Returns 

1294 ------- 

1295 Model 

1296 New model with the specified species removed. 

1297 

1298 Raises 

1299 ------ 

1300 ValueError 

1301 If any of the provided species is not found in the model. 

1302 """ 

1303 for s in species: 

1304 if s not in self.types: 

1305 raise ValueError(f'{s} is not a species supported by the NEP model') 

1306 

1307 new = copy.deepcopy(self) 

1308 types_to_keep = [t for t in self.types if t not in species] 

1309 new.types = tuple(types_to_keep) 

1310 

1311 # Prune ANN parameters 

1312 new.ann_parameters = { 

1313 key: value for key, value in new.ann_parameters.items() 

1314 if key in types_to_keep or key.startswith('b1') 

1315 } 

1316 

1317 # Prune descriptor weights; key is a (species1, species2) tuple 

1318 new.radial_descriptor_weights = { 

1319 key: value for key, value in new.radial_descriptor_weights.items() 

1320 if key[0] in types_to_keep and key[1] in types_to_keep 

1321 } 

1322 new.angular_descriptor_weights = { 

1323 key: value for key, value in new.angular_descriptor_weights.items() 

1324 if key[0] in types_to_keep and key[1] in types_to_keep 

1325 } 

1326 

1327 # Prune typewise cutoff lists so remaining species map to correct cutoffs 

1328 if isinstance(self.radial_cutoff, list): 

1329 indices = [i for i, t in enumerate(self.types) if t not in species] 

1330 new.radial_cutoff = [self.radial_cutoff[i] for i in indices] 

1331 new.angular_cutoff = [self.angular_cutoff[i] for i in indices] 

1332 

1333 # Prune restart parameters to match; survivors are left untouched 

1334 if new.restart_parameters is not None: 

1335 for param_type in ['mu', 'sigma']: 

1336 ann_key = f'ann_{param_type}' 

1337 # Keep per-species keys for survivors, global bias keys, and 

1338 # sqrt_epsilon_infinity (charge models) 

1339 new.restart_parameters[ann_key] = { 

1340 key: value for key, value in new.restart_parameters[ann_key].items() 

1341 if (key in types_to_keep or key.startswith('b1') 

1342 or key == 'sqrt_epsilon_infinity') 

1343 } 

1344 

1345 # Prune descriptor restart parameters 

1346 for desc_type in ['radial', 'angular']: 

1347 key = f'{desc_type}_descriptor_{param_type}' 

1348 new.restart_parameters[key] = { 

1349 k: v for k, v in new.restart_parameters[key].items() 

1350 if k[0] in types_to_keep and k[1] in types_to_keep 

1351 } 

1352 

1353 # Recalculate parameter counts 

1354 _recalculate_parameter_counts(new) 

1355 

1356 return new 

1357 

1358 def keep_species(self, species: list[str]) -> 'Model': 

1359 """Retain only the specified species, removing all others. 

1360 

1361 Convenience complement to :meth:`remove_species`. Useful when the set 

1362 of species to drop is large (e.g. isolating two elements from a 

1363 foundation model with dozens of species). 

1364 

1365 Parameters 

1366 ---------- 

1367 species 

1368 Species names to keep. All other species are removed. 

1369 

1370 Returns 

1371 ------- 

1372 Model 

1373 New model containing only the requested species. 

1374 

1375 Raises 

1376 ------ 

1377 ValueError 

1378 If any of the requested species is not in the model. 

1379 """ 

1380 unknown = [s for s in species if s not in self.types] 

1381 if unknown: 

1382 raise ValueError( 

1383 f'Species not in model: {unknown}' 

1384 ) 

1385 to_remove = [s for s in self.types if s not in species] 

1386 return self.remove_species(to_remove) 

1387 

1388 def reorder(self, order: list[str]) -> 'Model': 

1389 """Reorder the species in the model. 

1390 

1391 Returns a new :class:`Model` with species permuted according to 

1392 ``order``. This is useful for aligning the species order of two 

1393 models that must share the same order when used jointly by GPUMD, 

1394 e.g. a NEP potential and a TNEP dipole/polarizability model 

1395 referenced together via two ``potential`` lines in ``run.in`` and 

1396 ``dump_dipole`` or ``dump_polarizability``. 

1397 

1398 The source model is not modified. Since ``ann_parameters``, 

1399 ``radial_descriptor_weights``, ``angular_descriptor_weights``, and 

1400 ``restart_parameters`` are keyed by species name (or species-pair) 

1401 rather than position, reordering only requires updating ``types`` 

1402 and, if typewise cutoffs are in use, the positional 

1403 ``radial_cutoff`` and ``angular_cutoff`` lists. 

1404 

1405 Parameters 

1406 ---------- 

1407 order 

1408 New species order. Must be a permutation of ``self.types``. 

1409 

1410 Returns 

1411 ------- 

1412 Model 

1413 New model with species reordered. 

1414 

1415 Raises 

1416 ------ 

1417 ValueError 

1418 If ``order`` is not a permutation of the current species. 

1419 """ 

1420 if sorted(order) != sorted(self.types): 

1421 raise ValueError( 

1422 f'order must be a permutation of the current species {self.types}, ' 

1423 f'got {list(order)}' 

1424 ) 

1425 

1426 new = copy.deepcopy(self) 

1427 new.types = tuple(order) 

1428 

1429 if isinstance(self.radial_cutoff, list): 

1430 indices = [self.types.index(t) for t in order] 

1431 new.radial_cutoff = [self.radial_cutoff[i] for i in indices] 

1432 new.angular_cutoff = [self.angular_cutoff[i] for i in indices] 

1433 

1434 return new 

1435 

1436 def add_species(self, 

1437 species: list[str], 

1438 radial_cutoff: float | list[float] = None, 

1439 angular_cutoff: float | list[float] = None, 

1440 seed: int | None = None) -> 'Model': 

1441 """Add one or more species to the model. 

1442 

1443 Returns a new :class:`Model` with the requested species added. New ANN 

1444 sub-networks and descriptor weight pairs are initialized by drawing 

1445 ``mu`` uniformly from [-1, 1] (matching the GPUMD fresh-model 

1446 initialization); the corresponding restart sigma entries are left 

1447 unset (``NaN``). Call :meth:`set_restart_sigma` afterwards to 

1448 initialize them (e.g. ``model.add_species(['X']).set_restart_sigma()`` 

1449 fills only the new entries by default). Charge-specific parameters 

1450 (``w1_charge``) are kept at ``mu = 0`` to preserve stability, also 

1451 matching GPUMD. Existing parameters (``mu`` and ``sigma``) are left 

1452 untouched. Call :meth:`initialize_parameters` to draw the new values from 

1453 a different distribution instead, or to give ``w1_charge`` a non-zero start. 

1454 

1455 Parameters 

1456 ---------- 

1457 species 

1458 New species names to add. Appended to ``types`` in the order given. 

1459 radial_cutoff 

1460 Radial cutoff(s) for the new species, in Å. Required when the model 

1461 uses typewise cutoffs (i.e. ``isinstance(model.radial_cutoff, list)`` 

1462 is ``True``). Pass a single float or a list with one value per new 

1463 species. 

1464 angular_cutoff 

1465 Angular cutoff(s) for the new species, in Å. Same requirements as 

1466 ``radial_cutoff``. 

1467 seed 

1468 Seed for the random number generator used to draw the initial ``mu`` 

1469 values. Pass an integer for reproducible initialization. 

1470 

1471 Returns 

1472 ------- 

1473 Model 

1474 New model with updated structure, weights, and restart statistics. 

1475 

1476 Raises 

1477 ------ 

1478 ValueError 

1479 If the model version is not 4, if ``restart_parameters`` are not 

1480 loaded, if any species is already in the model, or if typewise 

1481 cutoffs are used and ``radial_cutoff``/``angular_cutoff`` are not 

1482 provided. 

1483 """ 

1484 if self.version != 4: 

1485 raise ValueError( 

1486 f'add_species() only supports NEP4 models; got version {self.version}.' 

1487 ) 

1488 for s in species: 

1489 if s in self.types: 

1490 raise ValueError(f'{s!r} is already in the model.') 

1491 if self.restart_parameters is None: 

1492 raise ValueError( 

1493 'restart_parameters must be loaded before calling add_species(). ' 

1494 'Pass restart_file= to read_model() or call model.read_restart() first.' 

1495 ) 

1496 

1497 uses_typewise = isinstance(self.radial_cutoff, list) 

1498 if uses_typewise: 

1499 if radial_cutoff is None or angular_cutoff is None: 

1500 raise ValueError( 

1501 'Model uses typewise cutoffs; provide radial_cutoff and angular_cutoff ' 

1502 'for the new species.' 

1503 ) 

1504 rc_list = ([radial_cutoff] * len(species) 

1505 if isinstance(radial_cutoff, (int, float)) else list(radial_cutoff)) 

1506 ac_list = ([angular_cutoff] * len(species) 

1507 if isinstance(angular_cutoff, (int, float)) else list(angular_cutoff)) 

1508 if len(rc_list) != len(species) or len(ac_list) != len(species): 

1509 raise ValueError( 

1510 'Length of radial_cutoff/angular_cutoff must match the number of new species.' 

1511 ) 

1512 

1513 new = copy.deepcopy(self) 

1514 

1515 n_descriptor = self.n_descriptor_radial + self.n_descriptor_angular 

1516 n_neuron = self.n_neuron 

1517 is_charged = self.model_type == 'potential_with_charges' 

1518 all_types_after = list(self.types) + list(species) 

1519 rng = np.random.default_rng(seed) 

1520 

1521 def _rand(shape): 

1522 return rng.uniform(-1.0, 1.0, size=shape) 

1523 

1524 # Step 1: New ANN sub-networks 

1525 w1_shape = (n_neuron,) if is_charged else (1, n_neuron) 

1526 for s_new in species: 

1527 w0_vals = _rand((n_neuron, n_descriptor)) 

1528 b0_vals = _rand((n_neuron, 1)) 

1529 w1_vals = _rand(w1_shape) 

1530 s_params = {'w0': w0_vals.copy(), 'b0': b0_vals.copy(), 'w1': w1_vals.copy()} 

1531 if is_charged: 

1532 s_params['w1_charge'] = np.zeros(n_neuron) 

1533 new.ann_parameters[s_new] = s_params 

1534 

1535 mu_entry = {'w0': w0_vals, 'b0': b0_vals, 'w1': w1_vals} 

1536 sigma_entry = { 

1537 'w0': np.full((n_neuron, n_descriptor), np.nan), 

1538 'b0': np.full((n_neuron, 1), np.nan), 

1539 'w1': np.full(w1_shape, np.nan), 

1540 } 

1541 if is_charged: 

1542 mu_entry['w1_charge'] = np.zeros(n_neuron) 

1543 sigma_entry['w1_charge'] = np.full(n_neuron, np.nan) 

1544 new.restart_parameters['ann_mu'][s_new] = mu_entry 

1545 new.restart_parameters['ann_sigma'][s_new] = sigma_entry 

1546 

1547 # Step 2: New descriptor weight pairs 

1548 n_r = (self.n_max_radial + 1, self.n_basis_radial + 1) 

1549 n_a = (self.n_max_angular + 1, self.n_basis_angular + 1) 

1550 existing_pairs = set(self.radial_descriptor_weights) 

1551 new_pairs = { 

1552 (s1, s2) 

1553 for s1 in all_types_after for s2 in all_types_after 

1554 if (s1, s2) not in existing_pairs 

1555 } 

1556 for pair in new_pairs: 

1557 r_vals = _rand(n_r) 

1558 a_vals = _rand(n_a) 

1559 new.radial_descriptor_weights[pair] = r_vals.copy() 

1560 new.angular_descriptor_weights[pair] = a_vals.copy() 

1561 new.restart_parameters['radial_descriptor_mu'][pair] = r_vals 

1562 new.restart_parameters['angular_descriptor_mu'][pair] = a_vals 

1563 new.restart_parameters['radial_descriptor_sigma'][pair] = np.full(n_r, np.nan) 

1564 new.restart_parameters['angular_descriptor_sigma'][pair] = np.full(n_a, np.nan) 

1565 

1566 # Step 3: Update types and typewise cutoffs 

1567 new.types = tuple(all_types_after) 

1568 if uses_typewise: 

1569 new.radial_cutoff = list(self.radial_cutoff) + rc_list 

1570 new.angular_cutoff = list(self.angular_cutoff) + ac_list 

1571 

1572 # Step 4: Recalculate parameter counts 

1573 _recalculate_parameter_counts(new) 

1574 

1575 return new 

1576 

1577 def write(self, filename: str, restart_file: str = None) -> None: 

1578 """Write NEP model to file in `nep.txt` format. 

1579 

1580 Parameters 

1581 ---------- 

1582 filename 

1583 Output file name for the NEP model. 

1584 restart_file 

1585 If provided, also write restart parameters to this file in 

1586 `nep.restart` format. Defaults to None. 

1587 """ 

1588 _require_nep4(self.version) 

1589 with open(filename, 'w') as f: 

1590 # header 

1591 version_name = f'nep{self.version}' 

1592 if self.zbl is not None: 

1593 version_name += '_zbl' 

1594 if self.model_type == 'potential_with_charges': 

1595 version_name += f'_charge{self.charge_mode}' 

1596 elif self.model_type != 'potential': 

1597 version_name += f'_{self.model_type}' 

1598 f.write(f'{version_name} {len(self.types)} {" ".join(self.types)}\n') 

1599 if self.zbl is not None: 

1600 zbl_tokens = list(self.zbl) 

1601 if self.zbl_typewise_cutoff_factor is not None: 

1602 zbl_tokens.append(self.zbl_typewise_cutoff_factor) 

1603 f.write(f'zbl {" ".join(map(_format_header_float, zbl_tokens))}\n') 

1604 f.write('cutoff') 

1605 if isinstance(self.radial_cutoff, float) and isinstance(self.angular_cutoff, float): 

1606 f.write(f' {_format_header_float(self.radial_cutoff)}' 

1607 f' {_format_header_float(self.angular_cutoff)}') 

1608 else: 

1609 # Typewise cutoffs: one set of cutoffs per type 

1610 for i in range(len(self.types)): 

1611 f.write(f' {_format_header_float(self.radial_cutoff[i])}' 

1612 f' {_format_header_float(self.angular_cutoff[i])}') 

1613 f.write(f' {self.max_neighbors_radial} {self.max_neighbors_angular}') 

1614 f.write('\n') 

1615 f.write(f'n_max {self.n_max_radial} {self.n_max_angular}\n') 

1616 f.write(f'basis_size {self.n_basis_radial} {self.n_basis_angular}\n') 

1617 l_max_line = f'l_max {self.l_max_3b} {self.l_max_4b} {self.l_max_5b}' 

1618 if self.has_q_112 or self.has_q_123 or self.has_q_233 or self.has_q_134: 

1619 l_max_line += f' {self.has_q_112}' 

1620 if self.has_q_123 or self.has_q_233 or self.has_q_134: 

1621 l_max_line += f' {self.has_q_123}' 

1622 if self.has_q_233 or self.has_q_134: 

1623 l_max_line += f' {self.has_q_233}' 

1624 if self.has_q_134: 

1625 l_max_line += f' {self.has_q_134}' 

1626 f.write(l_max_line + '\n') 

1627 f.write(f'ANN {self.n_neuron} 0\n') 

1628 

1629 # neural network weights 

1630 keys = self.types 

1631 suffixes = ['', '_polar'] if self.model_type == 'polarizability' else [''] 

1632 for suffix in suffixes: 

1633 for s in keys: 

1634 # Order: w0, b0, w1 

1635 # w0 indexed as: n*N_descriptor + nu 

1636 w0 = self.ann_parameters[s][f'w0{suffix}'] 

1637 b0 = self.ann_parameters[s][f'b0{suffix}'] 

1638 w1 = self.ann_parameters[s][f'w1{suffix}'] 

1639 for n in range(self.n_neuron): 

1640 for nu in range( 

1641 self.n_descriptor_radial + self.n_descriptor_angular 

1642 ): 

1643 f.write(f'{w0[n, nu]:15.7e}\n') 

1644 for b in b0[:, 0]: 

1645 f.write(f'{b:15.7e}\n') 

1646 for v in (w1[0, :] if w1.ndim == 2 else w1): 

1647 f.write(f'{v:15.7e}\n') 

1648 if f'w1_charge{suffix}' in self.ann_parameters[s]: 

1649 for v in self.ann_parameters[s][f'w1_charge{suffix}']: 

1650 f.write(f'{v:15.7e}\n') 

1651 if self.sqrt_epsilon_infinity is not None: 

1652 f.write(f'{self.sqrt_epsilon_infinity:15.7e}\n') 

1653 b1 = self.ann_parameters[f'b1{suffix}'] 

1654 f.write(f'{b1:15.7e}\n') 

1655 

1656 # descriptor weights 

1657 mat = np.concatenate([ 

1658 weights[(s1, s2)].flatten() 

1659 for s1 in self.types 

1660 for s2 in self.types 

1661 for weights in (self.radial_descriptor_weights, 

1662 self.angular_descriptor_weights) 

1663 ]) 

1664 n_types = len(self.types) 

1665 n = int(len(mat) / (n_types * n_types)) 

1666 mat = mat.reshape((n_types * n_types, n)).T 

1667 for v in mat.flatten(): 

1668 f.write(f'{v:15.7e}\n') 

1669 

1670 # scaler 

1671 for v in self.q_scaler: 

1672 f.write(f'{v:15.7e}\n') 

1673 

1674 if restart_file is not None: 

1675 self.write_restart(restart_file) 

1676 

1677 def read_restart(self, filename: str): 

1678 """Parses a file in `nep.restart` format and saves the 

1679 content in the form of mean and standard deviation for each 

1680 parameter in the corresponding NEP model. 

1681 

1682 Parameters 

1683 ---------- 

1684 filename 

1685 Input file name. 

1686 """ 

1687 mu, sigma = _get_restart_contents(filename) 

1688 restart_parameters = np.array([mu, sigma]).T 

1689 

1690 is_polarizability_model = self.model_type == 'polarizability' 

1691 is_charged_model = self.model_type == 'potential_with_charges' 

1692 

1693 n1 = self.n_ann_parameters 

1694 n1 *= 2 if is_polarizability_model else 1 

1695 n2 = n1 + self.n_descriptor_parameters 

1696 ann_parameters = restart_parameters[:n1] 

1697 descriptor_parameters = np.array(restart_parameters[n1:n2]) 

1698 

1699 ann_groups = [s for s in self.ann_parameters.keys() if not s.startswith('b1')] 

1700 n_descriptor = self.n_descriptor_radial + self.n_descriptor_angular 

1701 restart = {} 

1702 

1703 for i, content_type in enumerate(['mu', 'sigma']): 

1704 ann = _sort_ann_parameters(ann_parameters[:, i], 

1705 ann_groups, 

1706 self.n_neuron, 

1707 n_descriptor, 

1708 is_polarizability_model, 

1709 is_charged_model) 

1710 radial, angular = _sort_descriptor_parameters(descriptor_parameters[:, i], 

1711 self.types, 

1712 self.n_max_radial, 

1713 self.n_basis_radial, 

1714 self.n_max_angular, 

1715 self.n_basis_angular) 

1716 

1717 restart[f'ann_{content_type}'] = ann 

1718 restart[f'radial_descriptor_{content_type}'] = radial 

1719 restart[f'angular_descriptor_{content_type}'] = angular 

1720 self.restart_parameters = restart 

1721 

1722 def write_restart(self, filename: str): 

1723 """Write the restart parameters to file in `nep.restart` format. 

1724 

1725 Parameters 

1726 ---------- 

1727 filename 

1728 Output file name. 

1729 

1730 Raises 

1731 ------ 

1732 ValueError 

1733 If ``restart_parameters`` is not loaded, or if any restart sigma 

1734 value is unset (``NaN``), e.g. because :meth:`add_species` or 

1735 :meth:`augment` were called without a follow-up 

1736 :meth:`set_restart_sigma` to initialize the sigma of the newly 

1737 created parameters. 

1738 """ 

1739 if self.restart_parameters is None: 

1740 raise ValueError( 

1741 'restart_parameters is not loaded; nothing to write. Pass restart_file= ' 

1742 'to read_model(), call Model.read_restart(), or call ' 

1743 'Model.set_restart_sigma() to bootstrap one from the current model ' 

1744 'parameters before write_restart().' 

1745 ) 

1746 for _, sigma_container, sigma_key in _restart_leaves(self, self.restart_parameters): 

1747 if np.any(np.isnan(np.asarray(sigma_container[sigma_key], dtype=float))): 

1748 raise ValueError( 

1749 f'restart_parameters contains an unset (NaN) sigma value for ' 

1750 f'{sigma_key!r}. Call Model.set_restart_sigma() to initialize it ' 

1751 'before write_restart().' 

1752 ) 

1753 keys = self.types 

1754 suffixes = ['', '_polar'] if self.model_type == 'polarizability' else [''] 

1755 columns = [] 

1756 for i, parameter in enumerate(['mu', 'sigma']): 

1757 # neural network weights 

1758 ann_parameters = self.restart_parameters[f'ann_{parameter}'] 

1759 column = [] 

1760 for suffix in suffixes: 

1761 for s in keys: 

1762 # Order: w0, b0, w1 

1763 # w0 indexed as: n*N_descriptor + nu 

1764 w0 = ann_parameters[s][f'w0{suffix}'] 

1765 b0 = ann_parameters[s][f'b0{suffix}'] 

1766 w1 = ann_parameters[s][f'w1{suffix}'] 

1767 for n in range(self.n_neuron): 

1768 for nu in range( 

1769 self.n_descriptor_radial + self.n_descriptor_angular 

1770 ): 

1771 column.append(f'{w0[n, nu]:15.7e}') 

1772 for b in b0[:, 0]: 

1773 column.append(f'{b:15.7e}') 

1774 for v in (w1[0, :] if w1.ndim == 2 else w1): 

1775 column.append(f'{v:15.7e}') 

1776 if f'w1_charge{suffix}' in ann_parameters[s]: 

1777 for v in ann_parameters[s][f'w1_charge{suffix}']: 

1778 column.append(f'{v:15.7e}') 

1779 if f'sqrt_epsilon_infinity{suffix}' in ann_parameters: 

1780 column.append(f'{ann_parameters[f"sqrt_epsilon_infinity{suffix}"]:15.7e}') 

1781 b1 = ann_parameters[f'b1{suffix}'] 

1782 column.append(f'{b1:15.7e}') 

1783 columns.append(column) 

1784 

1785 # descriptor weights 

1786 radial_descriptor_parameters = self.restart_parameters[f'radial_descriptor_{parameter}'] 

1787 angular_descriptor_parameters = self.restart_parameters[ 

1788 f'angular_descriptor_{parameter}'] 

1789 mat = [] 

1790 for s1 in self.types: 

1791 for s2 in self.types: 

1792 mat = np.hstack( 

1793 [mat, radial_descriptor_parameters[(s1, s2)].flatten()] 

1794 ) 

1795 mat = np.hstack( 

1796 [mat, angular_descriptor_parameters[(s1, s2)].flatten()] 

1797 ) 

1798 n_types = len(self.types) 

1799 n = int(len(mat) / (n_types * n_types)) 

1800 mat = mat.reshape((n_types * n_types, n)).T 

1801 for v in mat.flatten(): 

1802 column.append(f'{v:15.7e}') 

1803 

1804 # Join the mean and standard deviation columns 

1805 assert len(columns[0]) == len(columns[1]), 'Length of means must match standard deviation' 

1806 joined = [f'{s1} {s2}\n' for s1, s2 in zip(*columns)] 

1807 with open(filename, 'w') as f: 

1808 f.writelines(joined) 

1809 

1810 def initialize_parameters(self, 

1811 strategy: str = 'uniform', 

1812 *, 

1813 value: float = None, 

1814 low: float = None, 

1815 high: float = None, 

1816 mean: float = None, 

1817 std: float = None, 

1818 species: str | list[str] = None, 

1819 component: str | list[str] = None, 

1820 target: str = 'unset', 

1821 seed: int | None = None) -> 'Model': 

1822 """Initialize parameter values from a distribution. 

1823 

1824 Returns a new :class:`Model` in which the parameters selected by 

1825 *target*/*species*/*component* are drawn according to *strategy*. The source model is 

1826 not modified. Every sigma value is left exactly as it was, so 

1827 :meth:`set_restart_sigma` remains the only method that assigns sigma. 

1828 

1829 Both the parameter values of the model (:attr:`ann_parameters` and the descriptor 

1830 weights, i.e. what goes into ``nep.txt``) and the corresponding ``mu`` entries of 

1831 :attr:`restart_parameters` (the SNES mean, i.e. what goes into ``nep.restart``) are 

1832 written. Only the selected positions are touched, since for a trained model the two 

1833 are not the same thing: ``nep.txt`` holds the best parameters found so far while 

1834 the restart holds the mean of the search distribution. 

1835 

1836 :attr:`sqrt_epsilon_infinity` is the one parameter no strategy reaches, whatever 

1837 *target*/*species*/*component* select. Every other parameter may take any value, but 

1838 ``epsilon_infinity`` is the square of this one, the high-frequency dielectric 

1839 constant, so a drawn value that is negative or close to zero is not a starting point. 

1840 Set it through the ``sqrt_epsilon_infinity`` argument of :meth:`augment` instead, 

1841 which defaults to 1. 

1842 

1843 The main use is to give the parameters created by :meth:`augment` a starting point 

1844 other than zero:: 

1845 

1846 model.augment(n_neuron=40) \\ 

1847 .initialize_parameters(strategy='uniform', low=-1, high=1, seed=42) \\ 

1848 .set_restart_sigma(strategy='constant', value=0.0, target='set') \\ 

1849 .set_restart_sigma(target='unset') 

1850 

1851 The order matters. :meth:`initialize_parameters` has to come before the 

1852 :meth:`set_restart_sigma` call that fills the unset sigma values, because an unset 

1853 sigma is what marks a parameter as new. Initializing the values first also makes the 

1854 default sigma strategy meaningful for the new parameters, since 

1855 ``strategy='scale_mu'`` computes ``sigma = max(floor, factor * |mu|)``, which is just 

1856 the floor as long as ``mu`` is zero. 

1857 

1858 Parameters 

1859 ---------- 

1860 strategy 

1861 How to draw the new values at the selected positions: 

1862 

1863 - ``'uniform'`` (default): ``value ~ U(low, high)``. Drawing from 

1864 ``U(-1, 1)`` reproduces what :meth:`add_species` does for a new sub-network. 

1865 - ``'constant'``: every selected parameter is set to ``value``. 

1866 - ``'normal'``: ``value ~ N(mean, std)``. 

1867 

1868 The values are used as drawn, including negative ones. 

1869 value 

1870 Value for ``strategy='constant'``. 

1871 low, high 

1872 Bounds for ``strategy='uniform'``. 

1873 mean, std 

1874 Parameters of the normal distribution for ``strategy='normal'``. 

1875 species 

1876 Restrict the update to one or more species (and descriptor pairs involving them). 

1877 ``None`` (default) applies to all species; the global shared bias is only 

1878 included when ``species`` is ``None``. 

1879 component 

1880 Restrict the update to one or more of ``'network_weights'``, ``'descriptor'``, 

1881 ``'charge_head'``. ``None`` (default) applies to all of them. ``'charge_head'`` 

1882 reaches ``w1_charge`` only, ``sqrt_epsilon_infinity`` being excluded as described 

1883 above. 

1884 target 

1885 Which parameters to initialize, selected by the state of the corresponding 

1886 *sigma*: ``'unset'`` (default) only the parameters whose sigma is unset (``NaN``), 

1887 i.e. those newly created by :meth:`augment` or :meth:`add_species`; ``'set'`` only 

1888 those that are already part of the search; ``'all'`` every selected parameter. 

1889 A value of zero is not used as the marker, since a trained parameter may be zero. 

1890 seed 

1891 Seed for the random number generator. Pass an integer for reproducibility. 

1892 

1893 Returns 

1894 ------- 

1895 Model 

1896 New model with the selected parameter values initialized. 

1897 

1898 Raises 

1899 ------ 

1900 ValueError 

1901 If ``restart_parameters`` is not loaded, if ``strategy``/``target``/``component`` 

1902 is not recognized, or if a strategy-specific required argument is missing. 

1903 

1904 Example 

1905 ------- 

1906 Give the neurons that :meth:`augment` added a random starting point:: 

1907 

1908 >>> # xdoctest: +SKIP 

1909 >>> model = read_model('nep.txt', restart_file='nep.restart') 

1910 >>> grown = model.augment(n_neuron=40) 

1911 >>> grown.ann_parameters['Pb']['w0'][30:].any() # new rows are zero 

1912 False 

1913 >>> initialized = grown.initialize_parameters(low=-1, high=1, seed=42) 

1914 >>> initialized.ann_parameters['Pb']['w0'][30:].any() 

1915 True 

1916 """ 

1917 valid_strategies = {'constant', 'uniform', 'normal'} 

1918 if strategy not in valid_strategies: 

1919 raise ValueError( 

1920 f'strategy must be one of {sorted(valid_strategies)}; got {strategy!r}' 

1921 ) 

1922 if target not in ('unset', 'set', 'all'): 

1923 raise ValueError(f"target must be 'unset', 'set', or 'all'; got {target!r}") 

1924 if strategy == 'constant' and value is None: 

1925 raise ValueError("strategy='constant' requires value.") 

1926 if strategy == 'uniform' and (low is None or high is None): 

1927 raise ValueError("strategy='uniform' requires low and high.") 

1928 if strategy == 'normal' and (mean is None or std is None): 

1929 raise ValueError("strategy='normal' requires mean and std.") 

1930 if self.restart_parameters is None: 

1931 raise ValueError( 

1932 'restart_parameters must be loaded before calling initialize_parameters(), ' 

1933 'since the sigma values are what mark a parameter as new. Pass restart_file= ' 

1934 'to read_model(), call model.read_restart(), or call ' 

1935 'Model.set_restart_sigma() to bootstrap one first.' 

1936 ) 

1937 

1938 new = copy.deepcopy(self) 

1939 rng = np.random.default_rng(seed) 

1940 kw = dict(value=value, low=low, high=high, mean=mean, std=std) 

1941 

1942 for container, key, owner, name in _model_parameter_leaves(new, component, species): 

1943 if name == 'sqrt_epsilon_infinity': 

1944 # Deliberately left out of every strategy. The other parameters may take any 

1945 # value, but epsilon_infinity is the square of this one, the high-frequency 

1946 # dielectric constant, so a drawn value that is negative or close to zero is 

1947 # not a starting point. augment(charge_head=True) sets it, via its 

1948 # sqrt_epsilon_infinity argument, and that value is what training starts from. 

1949 continue 

1950 restart_leaf = _restart_leaf_for(new.restart_parameters, owner, name, key) 

1951 if restart_leaf is None: 

1952 continue 

1953 mu_container, sigma_container, restart_key = restart_leaf 

1954 sigma = sigma_container[restart_key] 

1955 

1956 if np.isscalar(sigma) or isinstance(sigma, (float, int)): 

1957 is_unset = np.isnan(float(sigma)) 

1958 apply_here = ( 

1959 target == 'all' or (target == 'unset' and is_unset) 

1960 or (target == 'set' and not is_unset) 

1961 ) 

1962 if not apply_here: 

1963 continue 

1964 new_value = float(_draw_values(strategy, rng, **kw)) 

1965 # sqrt_epsilon_infinity is the one leaf whose container is the model, and 

1966 # it is skipped above, so every container reaching this write is a dict. 

1967 container[key] = new_value 

1968 mu_container[restart_key] = new_value 

1969 continue 

1970 

1971 values = _leaf_get(container, key) 

1972 mu = mu_container[restart_key] 

1973 if np.shape(values) != np.shape(sigma) or np.shape(mu) != np.shape(sigma): 

1974 raise ValueError( 

1975 f'Shape mismatch for {name!r}: the model holds {np.shape(values)} while ' 

1976 f'the restart holds {np.shape(mu)}. initialize_parameters() cannot map ' 

1977 'the two onto each other.' 

1978 ) 

1979 if target == 'unset': 

1980 mask = np.isnan(sigma) 

1981 elif target == 'set': 

1982 mask = ~np.isnan(sigma) 

1983 else: 

1984 mask = np.ones_like(sigma, dtype=bool) 

1985 if not np.any(mask): 

1986 continue 

1987 drawn = _draw_values(strategy, rng, size=int(np.count_nonzero(mask)), **kw) 

1988 values[mask] = drawn 

1989 mu[mask] = drawn 

1990 

1991 return new 

1992 

1993 def set_restart_sigma(self, 

1994 strategy: str = 'scale_mu', 

1995 *, 

1996 value: float = None, 

1997 factor: float = None, 

1998 floor: float = 1e-6, 

1999 low: float = None, 

2000 high: float = None, 

2001 mean: float = None, 

2002 std: float = None, 

2003 species: str | list[str] = None, 

2004 component: str | list[str] = None, 

2005 target: str = 'unset', 

2006 seed: int | None = None) -> 'Model': 

2007 """Assign SNES restart sigma values. 

2008 

2009 Returns a new :class:`Model` with sigma values updated according to 

2010 *strategy*, at the positions selected by *target*/*species*/*component*. 

2011 ``mu`` and every other field are left unchanged. This is the only 

2012 method that ever assigns sigma values; the structural methods 

2013 (:meth:`remove_species`, :meth:`keep_species`, :meth:`add_species`, 

2014 :meth:`augment`, :meth:`prune`) leave the sigma of the parameters they keep 

2015 untouched and mark newly created parameters' sigma as unset (``NaN``) rather 

2016 than computing a value inline. Parameter values are assigned by 

2017 :meth:`initialize_parameters` instead, which in turn never touches a sigma. 

2018 

2019 If ``restart_parameters`` is not loaded, it is created first: ``mu`` is 

2020 copied from the model's current (trained) parameters, and every sigma 

2021 is initialized as unset (``NaN``). This makes it possible to bootstrap 

2022 a ``nep.restart`` file "from scratch" for a plain ``nep.txt`` model. 

2023 

2024 Parameters 

2025 ---------- 

2026 strategy 

2027 How to compute new sigma values at the selected positions: 

2028 

2029 - ``'constant'``: ``sigma = value``. 

2030 - ``'scale_mu'`` (default): ``sigma = max(floor, factor * |mu|)``, 

2031 re-opening the SNES search width in proportion to each 

2032 parameter's magnitude. ``factor`` defaults to ``0.1`` for this 

2033 strategy. 

2034 - ``'scale_sigma'``: ``sigma = sigma * factor``. Requires the 

2035 selected sigma values to already be set (not ``NaN``). 

2036 - ``'uniform'``: draw ``sigma ~ U(low, high)``. 

2037 - ``'normal'``: draw ``sigma = |N(mean, std)|``. 

2038 value 

2039 Sigma value for ``strategy='constant'``. 

2040 factor 

2041 Scale factor for ``strategy='scale_mu'`` (default ``0.1`` if not 

2042 given) or ``strategy='scale_sigma'`` (required). 

2043 floor 

2044 Minimum sigma for ``strategy='scale_mu'``. 

2045 low, high 

2046 Bounds for ``strategy='uniform'``. Since a sigma is a standard 

2047 deviation, ``low`` must be positive and smaller than ``high``, which 

2048 keeps every drawn value positive. Use ``strategy='constant'`` for a 

2049 single value rather than ``low == high``. 

2050 mean, std 

2051 Parameters of the normal distribution for ``strategy='normal'``. 

2052 species 

2053 Restrict the update to one or more species (and descriptor pairs 

2054 involving them). ``None`` (default) applies to all species; global 

2055 parameters (the shared bias, ``sqrt_epsilon_infinity``) are only 

2056 included when ``species`` is ``None``. 

2057 component 

2058 Restrict the update to one or more of ``'network_weights'``, 

2059 ``'descriptor'``, ``'charge_head'``. ``None`` (default) applies to 

2060 all three. 

2061 target 

2062 Which existing sigma values to update: ``'unset'`` (default) only 

2063 fills in ``NaN`` entries (e.g. those left by :meth:`add_species`/ 

2064 :meth:`augment`); ``'set'`` only updates already-set entries; 

2065 ``'all'`` updates every selected entry regardless of its current 

2066 value. 

2067 seed 

2068 Seed for the random number generator used by the ``'uniform'`` and 

2069 ``'normal'`` strategies. Pass an integer for reproducibility. 

2070 

2071 Returns 

2072 ------- 

2073 Model 

2074 New model with updated restart sigma values. 

2075 

2076 Raises 

2077 ------ 

2078 ValueError 

2079 If ``strategy``/``target``/``component`` is not recognized, if a 

2080 strategy-specific required argument is missing, if 

2081 ``strategy='uniform'`` is given bounds that would admit a 

2082 non-positive sigma, or if ``strategy='scale_sigma'`` is applied to a 

2083 still-unset (``NaN``) sigma value. 

2084 """ 

2085 valid_strategies = {'constant', 'scale_mu', 'scale_sigma', 'uniform', 'normal'} 

2086 if strategy not in valid_strategies: 

2087 raise ValueError( 

2088 f'strategy must be one of {sorted(valid_strategies)}; got {strategy!r}' 

2089 ) 

2090 if target not in ('unset', 'set', 'all'): 

2091 raise ValueError(f"target must be 'unset', 'set', or 'all'; got {target!r}") 

2092 if strategy == 'constant' and value is None: 

2093 raise ValueError("strategy='constant' requires value.") 

2094 if strategy == 'scale_mu' and factor is None: 

2095 factor = 0.1 

2096 if strategy == 'scale_sigma' and factor is None: 

2097 raise ValueError("strategy='scale_sigma' requires factor.") 

2098 if strategy == 'uniform' and (low is None or high is None): 

2099 raise ValueError("strategy='uniform' requires low and high.") 

2100 # A sigma is a standard deviation, so the whole interval has to be positive. Both 

2101 # checks are needed: numpy samples between the two bounds whatever their order, so a 

2102 # positive low on its own does not bound the draw from below. 

2103 if strategy == 'uniform' and low <= 0: 

2104 raise ValueError( 

2105 "strategy='uniform' requires a positive low, since sigma is a standard " 

2106 f'deviation; got low={low!r}.' 

2107 ) 

2108 if strategy == 'uniform' and low >= high: 

2109 raise ValueError( 

2110 "strategy='uniform' requires low < high, so that every drawn sigma stays " 

2111 f"positive; got low={low!r} and high={high!r}. Use strategy='constant' for a " 

2112 'single value.' 

2113 ) 

2114 if strategy == 'normal' and (mean is None or std is None): 

2115 raise ValueError("strategy='normal' requires mean and std.") 

2116 

2117 new = copy.deepcopy(self) 

2118 if new.restart_parameters is None: 

2119 new.restart_parameters = _new_restart_parameters_from_model(new) 

2120 

2121 rng = np.random.default_rng(seed) 

2122 kw = dict(value=value, factor=factor, floor=floor, low=low, high=high, mean=mean, std=std) 

2123 for mu, sigma_container, sigma_key in _restart_leaves( 

2124 new, new.restart_parameters, component, species 

2125 ): 

2126 _apply_sigma_strategy(mu, sigma_container, sigma_key, strategy, target, rng, **kw) 

2127 

2128 return new 

2129 

2130 def augment(self, 

2131 n_neuron: int = None, 

2132 l_max_4b: int = None, 

2133 l_max_5b: int = None, 

2134 has_q_112: bool = None, 

2135 has_q_123: bool = None, 

2136 has_q_233: bool = None, 

2137 has_q_134: bool = None, 

2138 charge_head: bool = False, 

2139 charge_mode: int = 1, 

2140 sqrt_epsilon_infinity: float = 1.0) -> 'Model': 

2141 """Augment the model by adding neurons, descriptor terms, or a charge output head. 

2142 

2143 Returns a new :class:`Model` with the requested structural changes applied. 

2144 The source model is not modified. Existing parameter values (``mu`` and 

2145 ``sigma``) are preserved exactly; new parameters are initialized to 

2146 ``mu = 0``, with the corresponding restart sigma left unset (``NaN``). 

2147 ``sqrt_epsilon_infinity`` is the one exception: ``epsilon_infinity`` is its square, 

2148 a dielectric constant, so zero is not a value it can take. It starts at the value of 

2149 the ``sqrt_epsilon_infinity`` argument (1 by default) instead. 

2150 Call :meth:`set_restart_sigma` afterwards to initialize the new sigma 

2151 entries (e.g. ``model.augment(n_neuron=40).set_restart_sigma()`` fills 

2152 only the new entries by default). To give the new parameters a starting 

2153 value other than zero, call :meth:`initialize_parameters` before that, since 

2154 an unset sigma is what marks a parameter as new. 

2155 

2156 Parameters 

2157 ---------- 

2158 n_neuron 

2159 Target neuron count; must be >= current. ``None`` leaves unchanged. 

2160 l_max_4b 

2161 Target 4-body flag, 0 or 2; must be >= current. ``None`` leaves unchanged. 

2162 l_max_5b 

2163 Target 5-body l_max value; must be >= current. ``None`` leaves unchanged. 

2164 has_q_112 

2165 ``True`` enables the q_112 5-body descriptor; ``None`` or ``False`` leaves 

2166 the current state unchanged (disabling an already-enabled term raises). 

2167 has_q_123 

2168 Same as ``has_q_112`` but for the q_123 term. 

2169 has_q_233 

2170 Same as ``has_q_112`` but for the q_233 term. 

2171 has_q_134 

2172 Same as ``has_q_112`` but for the q_134 term. 

2173 charge_head 

2174 If ``True``, promote a ``potential`` model to ``potential_with_charges`` by 

2175 adding a charge output head (w1_charge per species and sqrt_epsilon_infinity). 

2176 charge_mode 

2177 Charge algorithm variant to record for the new charge head; must be 1 or 2. 

2178 1 corresponds to a qNEP model, including both real- and reciprocal-space 

2179 contributions. 2 corresponds to a qNEP model, including the reciprocal-space 

2180 contribution only. Only meaningful when ``charge_head=True``. 

2181 sqrt_epsilon_infinity 

2182 Starting value for the new ``sqrt_epsilon_infinity`` parameter, used only when 

2183 ``charge_head=True``. Must be positive: ``epsilon_infinity`` is its square, the 

2184 high-frequency dielectric constant. The default of 1 corresponds to no dielectric 

2185 screening, which is the neutral starting point for training. This is the one 

2186 parameter ``augment`` does not leave at zero, and :meth:`initialize_parameters` 

2187 skips it for the same reason, so the value given here is the one that reaches 

2188 training. 

2189 

2190 Returns 

2191 ------- 

2192 Model 

2193 New model with updated structure, weights, and restart statistics. 

2194 

2195 Raises 

2196 ------ 

2197 ValueError 

2198 If ``restart_parameters`` is not loaded, if ``n_neuron`` or an ``l_max_*`` 

2199 target is smaller than the current value, if ``l_max_4b`` is neither 0 nor 2, 

2200 if a ``has_q_*`` flag attempts to 

2201 disable an already-enabled term, or if ``charge_head=True`` on a model that 

2202 is not of type ``potential`` or comes with a non-positive 

2203 ``sqrt_epsilon_infinity``. 

2204 """ 

2205 # Structural checks (independent of restart) 

2206 if self.version != 4: 

2207 raise ValueError( 

2208 f'augment() only supports NEP4 models; got version {self.version}.' 

2209 ) 

2210 if n_neuron is not None and n_neuron < self.n_neuron: 

2211 raise ValueError( 

2212 f'n_neuron ({n_neuron}) must be >= current n_neuron ({self.n_neuron}); ' 

2213 'use prune() to reduce.' 

2214 ) 

2215 _check_l_max_4b(l_max_4b) 

2216 if l_max_4b is not None and l_max_4b < self.l_max_4b: 2216 ↛ 2217line 2216 didn't jump to line 2217 because the condition on line 2216 was never true

2217 raise ValueError( 

2218 f'l_max_4b ({l_max_4b}) must be >= current l_max_4b ({self.l_max_4b}); ' 

2219 'use prune() to disable.' 

2220 ) 

2221 if l_max_5b is not None and l_max_5b < self.l_max_5b: 

2222 raise ValueError( 

2223 f'l_max_5b ({l_max_5b}) must be >= current l_max_5b ({self.l_max_5b}); ' 

2224 'use prune() to disable.' 

2225 ) 

2226 for flag_val, name in [ 

2227 (has_q_112, 'has_q_112'), (has_q_123, 'has_q_123'), (has_q_233, 'has_q_233'), 

2228 (has_q_134, 'has_q_134') 

2229 ]: 

2230 if flag_val is False and getattr(self, name): 

2231 raise ValueError( 

2232 f'Cannot disable {name} via augment(); ' 

2233 'use prune() to disable descriptor terms.' 

2234 ) 

2235 if charge_head and self.model_type != 'potential': 

2236 raise ValueError( 

2237 f'charge_head=True requires model_type="potential"; ' 

2238 f'got "{self.model_type}".' 

2239 ) 

2240 if charge_head and charge_mode not in (1, 2): 

2241 raise ValueError(f'charge_mode must be 1 or 2; got {charge_mode}.') 

2242 if charge_head and not float(sqrt_epsilon_infinity) > 0: 

2243 raise ValueError( 

2244 'sqrt_epsilon_infinity must be positive; got ' 

2245 f'{sqrt_epsilon_infinity}. epsilon_infinity is its square, the ' 

2246 'high-frequency dielectric constant.' 

2247 ) 

2248 if self.restart_parameters is None: 

2249 raise ValueError( 

2250 'restart_parameters must be loaded before calling augment(). ' 

2251 'Pass restart_file= to read_model() or call model.read_restart() first.' 

2252 ) 

2253 

2254 new = copy.deepcopy(self) 

2255 

2256 # Resolve new structural parameters 

2257 new_l_max_4b = l_max_4b if l_max_4b is not None else self.l_max_4b 

2258 new_l_max_5b = l_max_5b if l_max_5b is not None else self.l_max_5b 

2259 new_has_q_112 = int(has_q_112) if has_q_112 is not None else self.has_q_112 

2260 new_has_q_123 = int(has_q_123) if has_q_123 is not None else self.has_q_123 

2261 new_has_q_233 = int(has_q_233) if has_q_233 is not None else self.has_q_233 

2262 new_has_q_134 = int(has_q_134) if has_q_134 is not None else self.has_q_134 

2263 new_n_neuron = n_neuron if n_neuron is not None else self.n_neuron 

2264 

2265 new_l_max_enh = (self.l_max_3b 

2266 + (new_l_max_4b > 0) + (new_l_max_5b > 0) 

2267 + (new_has_q_112 > 0) + (new_has_q_123 > 0) + (new_has_q_233 > 0) 

2268 + (new_has_q_134 > 0)) 

2269 new_n_desc_angular = (self.n_max_angular + 1) * new_l_max_enh 

2270 old_n_desc = self.n_descriptor_radial + self.n_descriptor_angular 

2271 new_n_desc = self.n_descriptor_radial + new_n_desc_angular 

2272 delta_desc = new_n_desc - old_n_desc 

2273 delta_neuron = new_n_neuron - self.n_neuron 

2274 

2275 keys = self.types 

2276 

2277 # Step 1: Expand descriptor dimensions (new columns in w0, new q_scaler entries) and 

2278 # neuron count (new entries of every head along its neuron axis) 

2279 for s in keys: 

2280 for params, fill in ((new.ann_parameters[s], 0.0), 

2281 (new.restart_parameters['ann_mu'][s], 0.0), 

2282 (new.restart_parameters['ann_sigma'][s], np.nan)): 

2283 for name, value in params.items(): 

2284 head, _ = _split_network_suffix(name) 

2285 if head == 'w0': 

2286 value = _append_filled(value, delta_desc, 1, fill) 

2287 params[name] = _append_filled(value, delta_neuron, _NEURON_AXIS[head], fill) 

2288 if delta_desc > 0: 

2289 new.q_scaler = list(new.q_scaler) + [1.0] * delta_desc 

2290 

2291 # Step 2: Add charge output head 

2292 if charge_head: 

2293 new.model_type = 'potential_with_charges' 

2294 new.charge_mode = charge_mode 

2295 new.sqrt_epsilon_infinity = float(sqrt_epsilon_infinity) 

2296 for s in keys: 

2297 cur_w1 = new.ann_parameters[s]['w1'] # (1, new_n_neuron) 

2298 new.ann_parameters[s]['w1'] = cur_w1[0, :] # flatten to 1D 

2299 new.ann_parameters[s]['w1_charge'] = np.zeros(new_n_neuron) 

2300 

2301 cur_mu_w1 = new.restart_parameters['ann_mu'][s]['w1'] 

2302 new.restart_parameters['ann_mu'][s]['w1'] = cur_mu_w1[0, :] 

2303 new.restart_parameters['ann_mu'][s]['w1_charge'] = np.zeros(new_n_neuron) 

2304 

2305 cur_sigma_w1 = new.restart_parameters['ann_sigma'][s]['w1'] 

2306 new.restart_parameters['ann_sigma'][s]['w1'] = cur_sigma_w1[0, :] 

2307 new.restart_parameters['ann_sigma'][s]['w1_charge'] = np.full( 

2308 new_n_neuron, np.nan 

2309 ) 

2310 

2311 new.restart_parameters['ann_mu']['sqrt_epsilon_infinity'] = float( 

2312 sqrt_epsilon_infinity 

2313 ) 

2314 new.restart_parameters['ann_sigma']['sqrt_epsilon_infinity'] = float('nan') 

2315 

2316 # Step 3: Update header metadata 

2317 new.l_max_4b = new_l_max_4b 

2318 new.l_max_5b = new_l_max_5b 

2319 new.has_q_112 = new_has_q_112 

2320 new.has_q_123 = new_has_q_123 

2321 new.has_q_233 = new_has_q_233 

2322 new.has_q_134 = new_has_q_134 

2323 new.n_descriptor_angular = new_n_desc_angular 

2324 new.n_neuron = new_n_neuron 

2325 

2326 # Step 4: Recalculate parameter counts 

2327 _recalculate_parameter_counts(new) 

2328 

2329 return new 

2330 

2331 def prune(self, 

2332 n_neuron: int = None, 

2333 l_max_4b: int = None, 

2334 l_max_5b: int = None, 

2335 has_q_112: bool = None, 

2336 has_q_123: bool = None, 

2337 has_q_233: bool = None, 

2338 has_q_134: bool = None, 

2339 charge_head: bool = False) -> 'Model': 

2340 """Prune the model by removing neurons, disabling descriptor terms, or removing 

2341 the charge output head. 

2342 

2343 Returns a new :class:`Model` with the requested structural changes applied. 

2344 The source model is not modified. When reducing ``n_neuron``, neurons are 

2345 selected by importance score averaged over species: 

2346 ``importance[n] = mean_s(||w0_s[n,:]||_2 * |w1_s[n]|)``. 

2347 The two networks of a polarizability model are pruned independently, each by the 

2348 importance of its own neurons. 

2349 

2350 Surviving parameters (``mu`` and ``sigma``) are left exactly as they 

2351 were. Use :meth:`set_restart_sigma` explicitly afterwards if you want 

2352 to re-open the SNES search width for the survivors before continuing 

2353 training. 

2354 

2355 Parameters 

2356 ---------- 

2357 n_neuron 

2358 Target neuron count; must be <= current. ``None`` leaves unchanged. 

2359 l_max_4b 

2360 Target 4-body flag, 0 or 2; must be <= current. Setting to ``0`` removes the 

2361 4-body angular descriptor block. ``None`` leaves unchanged. 

2362 l_max_5b 

2363 Target 5-body l_max; must be <= current. Setting to ``0`` removes the 

2364 5-body angular descriptor block. Reducing to a lower non-zero value is 

2365 a header-only change (descriptor dimensions unchanged). ``None`` leaves 

2366 unchanged. 

2367 has_q_112 

2368 ``False`` disables and removes the q_112 descriptor block. ``None`` 

2369 leaves unchanged. ``True`` is not valid; use :meth:`augment` instead. 

2370 has_q_123 

2371 Same as ``has_q_112`` but for the q_123 term. 

2372 has_q_233 

2373 Same as ``has_q_112`` but for the q_233 term. 

2374 has_q_134 

2375 Same as ``has_q_112`` but for the q_134 term. 

2376 charge_head 

2377 If ``True``, remove the charge output head from a 

2378 ``potential_with_charges`` model, converting it back to ``potential``. 

2379 Removes ``w1_charge`` per species and ``sqrt_epsilon_infinity`` from 

2380 the restart. 

2381 

2382 Returns 

2383 ------- 

2384 Model 

2385 New model with reduced structure, weights, and restart statistics. 

2386 

2387 Raises 

2388 ------ 

2389 ValueError 

2390 If ``restart_parameters`` is not loaded, if any target value would 

2391 expand the model (use :meth:`augment` instead), if ``l_max_4b`` is 

2392 neither 0 nor 2, if a ``has_q_*`` 

2393 flag is set to ``True``, or if ``charge_head=True`` on a model 

2394 without charges. 

2395 """ 

2396 # --- Resolve target values --- 

2397 new_n_neuron = n_neuron if n_neuron is not None else self.n_neuron 

2398 new_l_max_4b = l_max_4b if l_max_4b is not None else self.l_max_4b 

2399 new_l_max_5b = l_max_5b if l_max_5b is not None else self.l_max_5b 

2400 new_has_q_112 = 0 if has_q_112 is False else self.has_q_112 

2401 new_has_q_123 = 0 if has_q_123 is False else self.has_q_123 

2402 new_has_q_233 = 0 if has_q_233 is False else self.has_q_233 

2403 new_has_q_134 = 0 if has_q_134 is False else self.has_q_134 

2404 

2405 # --- Validate --- 

2406 if self.version != 4: 

2407 raise ValueError( 

2408 f'prune() only supports NEP4 models; got version {self.version}.' 

2409 ) 

2410 _check_l_max_4b(l_max_4b) 

2411 if new_n_neuron > self.n_neuron: 

2412 raise ValueError( 

2413 f'n_neuron ({new_n_neuron}) must be <= current n_neuron ({self.n_neuron}); ' 

2414 'use augment() to increase.' 

2415 ) 

2416 if new_l_max_4b > self.l_max_4b: 2416 ↛ 2417line 2416 didn't jump to line 2417 because the condition on line 2416 was never true

2417 raise ValueError( 

2418 f'l_max_4b ({new_l_max_4b}) must be <= current l_max_4b ({self.l_max_4b}); ' 

2419 'use augment() to increase.' 

2420 ) 

2421 if new_l_max_5b > self.l_max_5b: 

2422 raise ValueError( 

2423 f'l_max_5b ({new_l_max_5b}) must be <= current l_max_5b ({self.l_max_5b}); ' 

2424 'use augment() to increase.' 

2425 ) 

2426 for flag_val, name in [ 

2427 (has_q_112, 'has_q_112'), (has_q_123, 'has_q_123'), 

2428 (has_q_233, 'has_q_233'), (has_q_134, 'has_q_134') 

2429 ]: 

2430 if flag_val is True: 

2431 raise ValueError( 

2432 f'Cannot enable {name} via prune(); ' 

2433 'use augment() to enable descriptor terms.' 

2434 ) 

2435 if charge_head and self.model_type != 'potential_with_charges': 

2436 raise ValueError( 

2437 f'charge_head=True requires model_type="potential_with_charges"; ' 

2438 f'got "{self.model_type}".' 

2439 ) 

2440 if self.restart_parameters is None: 

2441 raise ValueError( 

2442 'restart_parameters must be loaded before calling prune(). ' 

2443 'Pass restart_file= to read_model() or call model.read_restart() first.' 

2444 ) 

2445 

2446 new = copy.deepcopy(self) 

2447 keys = self.types 

2448 

2449 # Step 1: Neuron pruning, keeping the most important neurons of each network 

2450 if new_n_neuron < self.n_neuron: 

2451 keep_idx = {} 

2452 networks = {_split_network_suffix(name)[1] for name in self.ann_parameters[keys[0]]} 

2453 for suffix in networks: 

2454 importances = [] 

2455 for s in keys: 

2456 heads = self.ann_parameters[s] 

2457 output_norm = np.abs(heads[f'w1{suffix}'].ravel()) 

2458 if f'w1_charge{suffix}' in heads: 

2459 output_norm = output_norm + np.abs(heads[f'w1_charge{suffix}']) 

2460 importances.append(np.linalg.norm(heads[f'w0{suffix}'], axis=1) * output_norm) 

2461 keep_idx[suffix] = np.sort( 

2462 np.argsort(np.mean(importances, axis=0))[-new_n_neuron:]) 

2463 

2464 for s in keys: 

2465 for params in (new.ann_parameters[s], new.restart_parameters['ann_mu'][s], 

2466 new.restart_parameters['ann_sigma'][s]): 

2467 for name, value in params.items(): 

2468 head, suffix = _split_network_suffix(name) 

2469 params[name] = np.take(value, keep_idx[suffix], axis=_NEURON_AXIS[head]) 

2470 

2471 # Step 2: Descriptor column pruning (disabling higher-body terms) 

2472 n_per = self.n_max_angular + 1 

2473 hb_terms = [ 

2474 (self.l_max_4b, new_l_max_4b), 

2475 (self.l_max_5b, new_l_max_5b), 

2476 (self.has_q_112, new_has_q_112), 

2477 (self.has_q_123, new_has_q_123), 

2478 (self.has_q_233, new_has_q_233), 

2479 (self.has_q_134, new_has_q_134), 

2480 ] 

2481 keep_cols = list(range(self.n_descriptor_radial + n_per * self.l_max_3b)) 

2482 col_offset = len(keep_cols) 

2483 for old_val, new_val in hb_terms: 

2484 if old_val > 0: 

2485 if new_val > 0: 

2486 keep_cols.extend(range(col_offset, col_offset + n_per)) 

2487 col_offset += n_per 

2488 

2489 old_n_desc = self.n_descriptor_radial + self.n_descriptor_angular 

2490 if len(keep_cols) < old_n_desc: 

2491 keep_cols = np.array(keep_cols, dtype=int) 

2492 for s in keys: 

2493 for params in (new.ann_parameters[s], new.restart_parameters['ann_mu'][s], 

2494 new.restart_parameters['ann_sigma'][s]): 

2495 for name, value in params.items(): 

2496 if _split_network_suffix(name)[0] == 'w0': 

2497 params[name] = value[:, keep_cols] 

2498 new.q_scaler = [new.q_scaler[i] for i in keep_cols] 

2499 

2500 # Step 3: Charge head removal 

2501 if charge_head: 

2502 new.model_type = 'potential' 

2503 new.charge_mode = 0 

2504 new.sqrt_epsilon_infinity = None 

2505 for s in keys: 

2506 w1 = new.ann_parameters[s]['w1'] # 1D (n_neuron,) 

2507 new.ann_parameters[s]['w1'] = w1.reshape(1, -1) 

2508 del new.ann_parameters[s]['w1_charge'] 

2509 for pk in ['ann_mu', 'ann_sigma']: 

2510 rp = new.restart_parameters[pk][s] 

2511 rp['w1'] = rp['w1'].reshape(1, -1) 

2512 del rp['w1_charge'] 

2513 del new.restart_parameters['ann_mu']['sqrt_epsilon_infinity'] 

2514 del new.restart_parameters['ann_sigma']['sqrt_epsilon_infinity'] 

2515 

2516 # Step 4: Update header fields 

2517 new.n_neuron = new_n_neuron 

2518 new.l_max_4b = new_l_max_4b 

2519 new.l_max_5b = new_l_max_5b 

2520 new.has_q_112 = new_has_q_112 

2521 new.has_q_123 = new_has_q_123 

2522 new.has_q_233 = new_has_q_233 

2523 new.has_q_134 = new_has_q_134 

2524 

2525 new_l_max_enh = (self.l_max_3b 

2526 + (new_l_max_4b > 0) + (new_l_max_5b > 0) 

2527 + (new_has_q_112 > 0) + (new_has_q_123 > 0) + (new_has_q_233 > 0) 

2528 + (new_has_q_134 > 0)) 

2529 new.n_descriptor_angular = (self.n_max_angular + 1) * new_l_max_enh 

2530 

2531 # Step 5: Recalculate parameter counts 

2532 _recalculate_parameter_counts(new) 

2533 

2534 return new 

2535 

2536 

2537def read_model(filename: str, restart_file: str = None) -> Model: 

2538 """Parses a file in ``nep.txt`` format and returns the 

2539 content in the form of a :class:`Model <calorine.nep.model.Model>` 

2540 object. 

2541 

2542 Parameters 

2543 ---------- 

2544 filename 

2545 Input file name. 

2546 restart_file 

2547 If provided, also read restart parameters from this file in 

2548 `nep.restart` format and attach them to the returned model. 

2549 Defaults to None. 

2550 """ 

2551 data, parameters = _get_nep_contents(filename) 

2552 

2553 # sanity checks 

2554 for fld in ['version', 'types', 'model_type', 'cutoff', 'basis_size', 'n_max', 'l_max', 'ANN']: 

2555 if fld not in data: 

2556 raise ValueError(f'Invalid model file; {fld} line is missing') 

2557 

2558 # split up zbl tuple (optional typewise cutoff factor as a third entry) 

2559 if 'zbl' in data: 

2560 if len(data['zbl']) == 3: 

2561 data['zbl_typewise_cutoff_factor'] = data['zbl'][2] 

2562 data['zbl'] = data['zbl'][:2] 

2563 elif len(data['zbl']) != 2: 

2564 raise ValueError( 

2565 f'Invalid model file; zbl line must have 2 or 3 entries, got {len(data["zbl"])}' 

2566 ) 

2567 

2568 # split up cutoff tuple 

2569 N_types = len(data['types']) 

2570 # Either global cutoffs + max neighbirs, or typewise cutoffs + max_neighbors 

2571 if len(data['cutoff']) not in [4, 2*N_types+2]: 

2572 raise ValueError( 

2573 'Invalid model file; cutoff line must have 4 entries (global cutoffs) or ' 

2574 f'{2*N_types+2} entries (typewise cutoffs for {N_types} types), ' 

2575 f'got {len(data["cutoff"])}' 

2576 ) 

2577 if not all(np.isfinite(data['cutoff'])): 

2578 raise ValueError('Invalid model file; cutoff values must be finite') 

2579 data['max_neighbors_radial'] = int(data['cutoff'][-2]) 

2580 data['max_neighbors_angular'] = int(data['cutoff'][-1]) 

2581 if len(data['cutoff']) == 2*N_types+2: 

2582 # Typewise cutoffs: radial are even, angular are odd 

2583 data['radial_cutoff'] = [data['cutoff'][i*2] for i in range(N_types)] 

2584 data['angular_cutoff'] = [data['cutoff'][i*2+1] for i in range(N_types)] 

2585 else: 

2586 data['radial_cutoff'] = data['cutoff'][0] 

2587 data['angular_cutoff'] = data['cutoff'][1] 

2588 del data['cutoff'] 

2589 

2590 # split up basis_size tuple 

2591 if len(data['basis_size']) != 2: 

2592 raise ValueError( 

2593 f'Invalid model file; basis_size line must have 2 entries, ' 

2594 f'got {len(data["basis_size"])}' 

2595 ) 

2596 data['n_basis_radial'] = data['basis_size'][0] 

2597 data['n_basis_angular'] = data['basis_size'][1] 

2598 del data['basis_size'] 

2599 

2600 # split up n_max tuple 

2601 if len(data['n_max']) != 2: 

2602 raise ValueError( 

2603 f'Invalid model file; n_max line must have 2 entries, got {len(data["n_max"])}' 

2604 ) 

2605 data['n_max_radial'] = data['n_max'][0] 

2606 data['n_max_angular'] = data['n_max'][1] 

2607 del data['n_max'] 

2608 

2609 # split up nl_max tuple 

2610 len_l = len(data['l_max']) 

2611 if len_l not in [1, 2, 3, 4, 5, 6, 7]: 

2612 raise ValueError( 

2613 f'Invalid model file; l_max line must have between 1 and 7 entries, got {len_l}' 

2614 ) 

2615 data['l_max_3b'] = data['l_max'][0] 

2616 # the four-body field is a flag that GPUMD has written as 1 as well as 2 

2617 data['l_max_4b'] = 2 if len_l > 1 and data['l_max'][1] else 0 

2618 data['l_max_5b'] = data['l_max'][2] if len_l > 2 else 0 

2619 data['has_q_112'] = data['l_max'][3] if len_l > 3 else 0 

2620 data['has_q_123'] = data['l_max'][4] if len_l > 4 else 0 

2621 data['has_q_233'] = data['l_max'][5] if len_l > 5 else 0 

2622 data['has_q_134'] = data['l_max'][6] if len_l > 6 else 0 

2623 del data['l_max'] 

2624 

2625 # compute dimensions of descriptor components 

2626 data['n_descriptor_radial'] = data['n_max_radial'] + 1 

2627 l_max_enh = (data['l_max_3b'] 

2628 + (data['l_max_4b'] > 0) 

2629 + (data['l_max_5b'] > 0) 

2630 + (data['has_q_112'] > 0) 

2631 + (data['has_q_123'] > 0) 

2632 + (data['has_q_233'] > 0) 

2633 + (data['has_q_134'] > 0)) 

2634 data['n_descriptor_angular'] = (data['n_max_angular'] + 1) * l_max_enh 

2635 n_descriptor = data['n_descriptor_radial'] + data['n_descriptor_angular'] 

2636 

2637 is_charged_model = data['model_type'] == 'potential_with_charges' 

2638 # compute number of parameters 

2639 data['n_neuron'] = data['ANN'][0] 

2640 del data['ANN'] 

2641 n_types = len(data['types']) 

2642 # one hidden layer per atomic species 

2643 n = n_types 

2644 n_output_biases = _number_of_output_biases(is_charged_model) 

2645 

2646 n_ann_input_weights = (n_descriptor + 1) * data['n_neuron'] # weights + bias 

2647 n_ann_output_weights = 2*data['n_neuron'] if is_charged_model else data['n_neuron'] # weights 

2648 n_ann_parameters = ( 

2649 n_ann_input_weights + n_ann_output_weights 

2650 ) * n + n_output_biases 

2651 

2652 n_descriptor_weights = n_types**2 * ( 

2653 (data['n_max_radial'] + 1) * (data['n_basis_radial'] + 1) 

2654 + (data['n_max_angular'] + 1) * (data['n_basis_angular'] + 1) 

2655 ) 

2656 data['n_parameters'] = n_ann_parameters + n_descriptor_weights + n_descriptor 

2657 is_polarizability_model = data['model_type'] == 'polarizability' 

2658 if data['n_parameters'] + n_ann_parameters == len(parameters): 

2659 data['n_parameters'] += n_ann_parameters 

2660 if not is_polarizability_model: 

2661 raise ValueError( 

2662 'Model is not labeled as a polarizability model, but the number of ' 

2663 'parameters matches a polarizability model.\n' 

2664 'If this is a polarizability model trained with GPUMD <=v3.8, please ' 

2665 'modify the header in the nep.txt file to enable parsing ' 

2666 f'`nep{data["version"]}_polarizability`.\n' 

2667 ) 

2668 if len(parameters) < data['n_parameters']: 

2669 raise ValueError( 

2670 'Invalid model file; expected ' 

2671 f'{data["n_parameters"]} parameter values, found {len(parameters)} ' 

2672 '-- file may be truncated' 

2673 ) 

2674 elif len(parameters) > data['n_parameters']: 

2675 raise ValueError( 

2676 'Invalid model file; expected ' 

2677 f'{data["n_parameters"]} parameter values, found {len(parameters)} ' 

2678 '-- file may contain extra or corrupted data' 

2679 ) 

2680 data['n_ann_parameters'] = n_ann_parameters 

2681 

2682 # split up parameters into the ANN weights, descriptor weights, and scaling parameters 

2683 n1 = n_ann_parameters 

2684 n1 *= 2 if is_polarizability_model else 1 

2685 n2 = n1 + n_descriptor_weights 

2686 data['ann_parameters'] = parameters[:n1] 

2687 descriptor_weights = np.array(parameters[n1:n2]) 

2688 data['q_scaler'] = parameters[n2:] 

2689 

2690 # add ann parameters to data dict 

2691 ann_groups = data['types'] 

2692 sorted_ann_parameters = _sort_ann_parameters(data['ann_parameters'], 

2693 ann_groups, 

2694 data['n_neuron'], 

2695 n_descriptor, 

2696 is_polarizability_model, 

2697 is_charged_model) 

2698 

2699 data['ann_parameters'] = sorted_ann_parameters 

2700 if 'sqrt_epsilon_infinity' in sorted_ann_parameters.keys(): 

2701 data['sqrt_epsilon_infinity'] = sorted_ann_parameters['sqrt_epsilon_infinity'] 

2702 sorted_ann_parameters.pop('sqrt_epsilon_infinity') 

2703 data['ann_parameters'] = sorted_ann_parameters 

2704 

2705 # add descriptors to data dict 

2706 data['n_descriptor_parameters'] = len(descriptor_weights) 

2707 radial, angular = _sort_descriptor_parameters(descriptor_weights, 

2708 data['types'], 

2709 data['n_max_radial'], 

2710 data['n_basis_radial'], 

2711 data['n_max_angular'], 

2712 data['n_basis_angular']) 

2713 data['radial_descriptor_weights'] = radial 

2714 data['angular_descriptor_weights'] = angular 

2715 

2716 model = Model(**data) 

2717 if restart_file is not None: 

2718 model.read_restart(restart_file) 

2719 return model