Coverage for src/bioimageio/core/digest_spec.py: 83%

247 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 15:59 +0000

1from __future__ import annotations 

2 

3import collections.abc 

4import importlib.util 

5import sys 

6from itertools import chain 

7from pathlib import Path 

8from tempfile import TemporaryDirectory 

9from typing import ( 

10 Any, 

11 Callable, 

12 Dict, 

13 Iterable, 

14 List, 

15 Literal, 

16 Mapping, 

17 NamedTuple, 

18 Optional, 

19 Sequence, 

20 Tuple, 

21 Union, 

22) 

23from zipfile import ZipFile, is_zipfile 

24 

25import numpy as np 

26import xarray as xr 

27from loguru import logger 

28from numpy.typing import NDArray 

29from typing_extensions import TypeAlias, Unpack, assert_never 

30 

31from bioimageio.spec._internal.io import HashKwargs, PermissiveFileSource 

32from bioimageio.spec.common import FileDescr, FileSource 

33from bioimageio.spec.model import AnyModelDescr, v0_4, v0_5 

34from bioimageio.spec.model.v0_4 import CallableFromDepencency, CallableFromFile 

35from bioimageio.spec.model.v0_5 import ( 

36 ArchitectureFromFileDescr, 

37 ArchitectureFromLibraryDescr, 

38 ParameterizedSize_N, 

39) 

40from bioimageio.spec.utils import load_array 

41 

42from .axis import AxisId, AxisInfo, AxisLike, PerAxis 

43from .block_meta import split_multiple_shapes_into_blocks 

44from .common import Halo, MemberId, PerMember, SampleId, TotalNumberOfBlocks 

45from .io import load_tensor 

46from .sample import ( 

47 LinearSampleAxisTransform, 

48 Sample, 

49 SampleBlockMeta, 

50 SampleBlockWithOrigin, 

51 sample_block_meta_generator, 

52) 

53from .stat_measures import Stat 

54from .tensor import Tensor 

55 

56TensorSource: TypeAlias = Union[ 

57 Tensor, xr.DataArray, NDArray[Any], PermissiveFileSource 

58] 

59 

60 

61def import_callable( 

62 node: Union[ 

63 ArchitectureFromFileDescr, 

64 ArchitectureFromLibraryDescr, 

65 CallableFromDepencency, 

66 CallableFromFile, 

67 v0_5.CustomProcessingDescr, 

68 ], 

69 /, 

70 **kwargs: Unpack[HashKwargs], 

71) -> Callable[..., Any]: 

72 """import a callable (e.g. a torch.nn.Module) from a spec node describing it""" 

73 if isinstance(node, CallableFromDepencency): 

74 module = importlib.import_module(node.module_name) 

75 c = getattr(module, str(node.callable_name)) 

76 elif isinstance(node, ArchitectureFromLibraryDescr): 

77 module = importlib.import_module(node.import_from) 

78 c = getattr(module, str(node.callable)) 

79 elif isinstance(node, CallableFromFile): 

80 c = _import_from_file_impl(node.source_file, str(node.callable_name), **kwargs) 

81 elif isinstance(node, (ArchitectureFromFileDescr, v0_5.CustomProcessingDescr)): 

82 c = _import_from_file_impl(node.source, str(node.callable), sha256=node.sha256) 

83 else: 

84 assert_never(node) 

85 

86 if not callable(c): 

87 raise ValueError(f"{node} (imported: {c}) is not callable") 

88 

89 return c 

90 

91 

92tmp_dirs_in_use: List[TemporaryDirectory[str]] = [] 

93"""keep global reference to temporary directories created during import to delay cleanup""" 

94 

95 

96def _import_from_file_impl( 

97 source: FileSource, callable_name: str, **kwargs: Unpack[HashKwargs] 

98): 

99 src_descr = FileDescr(source=source, **kwargs) 

100 # ensure sha is valid even if perform_io_checks=False 

101 # or the source has changed since last sha computation 

102 src_descr.validate_sha256(force_recompute=True) 

103 assert src_descr.sha256 is not None 

104 source_sha = src_descr.sha256 

105 

106 reader = src_descr.get_reader() 

107 # make sure we have unique module name 

108 module_name = f"{reader.original_file_name.split('.')[0]}_{source_sha}" 

109 

110 # make sure we have a unique and valid module name 

111 if not module_name.isidentifier(): 

112 module_name = f"custom_module_{source_sha}" 

113 assert module_name.isidentifier(), module_name 

114 

115 source_bytes = reader.read() 

116 

117 module = sys.modules.get(module_name) 

118 if module is None: 

119 try: 

120 td_kwargs: Dict[str, Any] = ( 

121 dict(ignore_cleanup_errors=True) if sys.version_info >= (3, 10) else {} 

122 ) 

123 if sys.version_info >= (3, 12): 

124 td_kwargs["delete"] = False 

125 

126 tmp_dir = TemporaryDirectory(**td_kwargs) 

127 # keep global ref to tmp_dir to delay cleanup until program exit 

128 # TODO: remove for py >= 3.12, when delete=False works 

129 tmp_dirs_in_use.append(tmp_dir) 

130 

131 module_path = Path(tmp_dir.name) / module_name 

132 if reader.original_file_name.endswith(".zip") or is_zipfile(reader): 

133 module_path.mkdir() 

134 ZipFile(reader).extractall(path=module_path) 

135 else: 

136 module_path = module_path.with_suffix(".py") 

137 _ = module_path.write_bytes(source_bytes) 

138 

139 importlib_spec = importlib.util.spec_from_file_location( 

140 module_name, str(module_path) 

141 ) 

142 

143 if importlib_spec is None: 

144 raise ImportError(f"Failed to import {source}") 

145 

146 module = importlib.util.module_from_spec(importlib_spec) 

147 

148 sys.modules[module_name] = module # cache this module 

149 

150 assert importlib_spec.loader is not None 

151 importlib_spec.loader.exec_module(module) 

152 

153 except Exception as e: 

154 if module_name in sys.modules: 

155 del sys.modules[module_name] 

156 

157 raise ImportError(f"Failed to import {source}") from e 

158 

159 try: 

160 callable_attr = getattr(module, callable_name) 

161 except AttributeError as e: 

162 raise AttributeError( 

163 f"Imported custom module from {source} has no `{callable_name}` attribute." 

164 ) from e 

165 except Exception as e: 

166 raise AttributeError( 

167 f"Failed to access `{callable_name}` attribute from custom module imported from {source} ." 

168 ) from e 

169 

170 else: 

171 return callable_attr 

172 

173 

174def get_axes_infos( 

175 io_descr: Union[ 

176 v0_4.InputTensorDescr, 

177 v0_4.OutputTensorDescr, 

178 v0_5.InputTensorDescr, 

179 v0_5.OutputTensorDescr, 

180 ], 

181) -> List[AxisInfo]: 

182 """get a unified, simplified axis representation from spec axes""" 

183 return [AxisInfo.create(a) for a in io_descr.axes] 

184 

185 

186def get_member_id( 

187 tensor_description: Union[ 

188 v0_4.InputTensorDescr, 

189 v0_4.OutputTensorDescr, 

190 v0_5.InputTensorDescr, 

191 v0_5.OutputTensorDescr, 

192 ], 

193) -> MemberId: 

194 """get the normalized tensor ID, usable as a sample member ID""" 

195 

196 if isinstance(tensor_description, (v0_4.InputTensorDescr, v0_4.OutputTensorDescr)): 

197 return MemberId(tensor_description.name) 

198 elif isinstance( 

199 tensor_description, (v0_5.InputTensorDescr, v0_5.OutputTensorDescr) 

200 ): 

201 return tensor_description.id 

202 else: 

203 assert_never(tensor_description) 

204 

205 

206def get_member_ids( 

207 tensor_descriptions: Iterable[ 

208 Union[ 

209 v0_4.InputTensorDescr, 

210 v0_4.OutputTensorDescr, 

211 v0_5.InputTensorDescr, 

212 v0_5.OutputTensorDescr, 

213 ] 

214 ], 

215) -> List[MemberId]: 

216 """get normalized tensor IDs to be used as sample member IDs""" 

217 return [get_member_id(descr) for descr in tensor_descriptions] 

218 

219 

220def get_test_input_sample(model: AnyModelDescr) -> Sample: 

221 if isinstance(model, v0_4.ModelDescr): 

222 info = { 

223 MemberId(d.name): (d, t) for d, t in zip(model.inputs, model.test_inputs) 

224 } 

225 else: 

226 info = {d.id: d for d in model.inputs} 

227 

228 return _get_test_sample(info) 

229 

230 

231get_test_inputs = get_test_input_sample 

232"""DEPRECATED: use `get_test_input_sample` instead""" 

233 

234 

235def get_test_output_sample(model: AnyModelDescr) -> Sample: 

236 """returns a model's test output sample""" 

237 if isinstance(model, v0_4.ModelDescr): 

238 info = { 

239 MemberId(d.name): (d, t) for d, t in zip(model.outputs, model.test_outputs) 

240 } 

241 else: 

242 info = {d.id: d for d in model.outputs} 

243 

244 return _get_test_sample(info) 

245 

246 

247get_test_outputs = get_test_output_sample 

248"""DEPRECATED: use `get_test_output_sample` instead""" 

249 

250 

251def _get_test_sample( 

252 info: Union[ 

253 Mapping[MemberId, Union[v0_5.InputTensorDescr, v0_5.OutputTensorDescr]], 

254 Mapping[ 

255 MemberId, 

256 Tuple[ 

257 v0_4.InputTensorDescr, 

258 FileSource, 

259 ], 

260 ], 

261 Mapping[ 

262 MemberId, 

263 Tuple[ 

264 v0_4.OutputTensorDescr, 

265 FileSource, 

266 ], 

267 ], 

268 ], 

269) -> Sample: 

270 arrays: Dict[MemberId, NDArray[Any]] = {} 

271 for m, src in info.items(): 

272 if isinstance(src, tuple): 

273 arrays[m] = load_array(src[1]) 

274 elif isinstance(src, (v0_5.InputTensorDescr, v0_5.OutputTensorDescr)): 

275 if src.test_tensor is None: 

276 raise ValueError( 

277 f"Model input '{m}' has no test tensor defined, cannot create test sample." 

278 ) 

279 arrays[m] = load_array(src.test_tensor) 

280 else: 

281 assert_never(src) 

282 

283 axes = { 

284 m: get_axes_infos(t[0] if isinstance(t, tuple) else t) for m, t in info.items() 

285 } 

286 return Sample( 

287 members={m: Tensor.from_numpy(arrays[m], dims=axes[m]) for m in info}, 

288 stat={}, 

289 id="test-sample", 

290 ) 

291 

292 

293class IO_SampleBlockMeta(NamedTuple): 

294 input: SampleBlockMeta 

295 output: SampleBlockMeta 

296 

297 

298def get_input_halo( 

299 model: v0_5.ModelDescr, output_halo: Optional[PerMember[PerAxis[Halo]]] = None 

300): 

301 """returns which halo input tensors need to be divided into blocks with, such that 

302 `output_halo` can be cropped from their outputs without introducing gaps.""" 

303 input_halo: Dict[MemberId, Dict[AxisId, Halo]] = {} 

304 outputs = {t.id: t for t in model.outputs} 

305 all_tensors = {**{t.id: t for t in model.inputs}, **outputs} 

306 if output_halo is None: 

307 output_halo = { 

308 t.id: { 

309 a.id: Halo(a.halo, a.halo) 

310 for a in t.axes 

311 if isinstance(a, v0_5.WithHalo) 

312 } 

313 for t in model.outputs 

314 } 

315 

316 for t, th in output_halo.items(): 

317 axes = {a.id: a for a in outputs[t].axes} 

318 

319 for a, ah in th.items(): 

320 s = axes[a].size 

321 if not isinstance(s, v0_5.SizeReference): 

322 raise ValueError( 

323 f"Unable to map output halo for {t}.{a} to an input axis" 

324 ) 

325 

326 axis = axes[a] 

327 ref_axis = {a.id: a for a in all_tensors[s.tensor_id].axes}[s.axis_id] 

328 

329 input_halo_left = ah.left * axis.scale / ref_axis.scale 

330 input_halo_right = ah.right * axis.scale / ref_axis.scale 

331 assert input_halo_left == int(input_halo_left), f"{input_halo_left} not int" 

332 assert input_halo_right == int(input_halo_right), ( 

333 f"{input_halo_right} not int" 

334 ) 

335 

336 input_halo.setdefault(s.tensor_id, {})[a] = Halo( 

337 int(input_halo_left), int(input_halo_right) 

338 ) 

339 

340 return input_halo 

341 

342 

343def get_block_transform( 

344 model: v0_5.ModelDescr, 

345) -> PerMember[PerAxis[Union[LinearSampleAxisTransform, int]]]: 

346 """returns how a model's output tensor shapes relates to its input shapes""" 

347 ret: Dict[MemberId, Dict[AxisId, Union[LinearSampleAxisTransform, int]]] = {} 

348 batch_axis_trf = None 

349 for ipt in model.inputs: 

350 for a in ipt.axes: 

351 if a.type == "batch": 

352 batch_axis_trf = LinearSampleAxisTransform( 

353 axis=a.id, scale=1, offset=0, member=ipt.id 

354 ) 

355 break 

356 if batch_axis_trf is not None: 

357 break 

358 axis_scales = { 

359 t.id: {a.id: a.scale for a in t.axes} 

360 for t in chain(model.inputs, model.outputs) 

361 } 

362 for out in model.outputs: 

363 new_axes: Dict[AxisId, Union[LinearSampleAxisTransform, int]] = {} 

364 for a in out.axes: 

365 if a.size is None: 

366 assert a.type == "batch" 

367 if batch_axis_trf is None: 

368 raise ValueError( 

369 "no batch axis found in any input tensor, but output tensor" 

370 + f" '{out.id}' has one." 

371 ) 

372 s = batch_axis_trf 

373 elif isinstance(a.size, int): 

374 s = a.size 

375 elif isinstance(a.size, v0_5.DataDependentSize): 

376 s = -1 

377 elif isinstance(a.size, v0_5.SizeReference): 

378 s = LinearSampleAxisTransform( 

379 axis=a.size.axis_id, 

380 scale=axis_scales[a.size.tensor_id][a.size.axis_id] / a.scale, 

381 offset=a.size.offset, 

382 member=a.size.tensor_id, 

383 ) 

384 else: 

385 assert_never(a.size) 

386 

387 new_axes[a.id] = s 

388 

389 # account for postprocessing that changes the nubmer of output channels by 

390 # overwriting described output shape by the intermediate output shape 

391 c = AxisId("channel") 

392 if c not in new_axes: 

393 continue 

394 for post in out.postprocessing: 

395 if post.id == "cellpose_flow_dynamics": 

396 new_axes[c] = 3 

397 break 

398 elif post.id == "stardist_postprocessing": 

399 new_axes[c] = post.kwargs.n_rays + 1 

400 break 

401 

402 ret[out.id] = new_axes 

403 

404 return ret 

405 

406 

407def get_io_sample_block_metas( 

408 model: v0_5.ModelDescr, 

409 input_sample_shape: PerMember[PerAxis[int]], 

410 ns: Mapping[Tuple[MemberId, AxisId], ParameterizedSize_N], 

411 batch_size: int = 1, 

412) -> Tuple[TotalNumberOfBlocks, Iterable[IO_SampleBlockMeta]]: 

413 """returns an iterable yielding meta data for corresponding input and output samples""" 

414 if not isinstance(model, v0_5.ModelDescr): 

415 raise TypeError(f"get_block_meta() not implemented for {type(model)}") 

416 

417 block_axis_sizes = model.get_axis_sizes(ns=ns, batch_size=batch_size) 

418 input_block_shape = { 

419 t: {aa: s for (tt, aa), s in block_axis_sizes.inputs.items() if tt == t} 

420 for t in {tt for tt, _ in block_axis_sizes.inputs} 

421 } 

422 output_halo = { 

423 t.id: { 

424 a.id: Halo(a.halo, a.halo) for a in t.axes if isinstance(a, v0_5.WithHalo) 

425 } 

426 for t in model.outputs 

427 } 

428 input_halo = get_input_halo(model, output_halo) 

429 

430 n_input_blocks, input_blocks = split_multiple_shapes_into_blocks( 

431 input_sample_shape, input_block_shape, halo=input_halo 

432 ) 

433 block_transform = get_block_transform(model) 

434 return n_input_blocks, ( 

435 IO_SampleBlockMeta(ipt, ipt.get_transformed(block_transform)) 

436 for ipt in sample_block_meta_generator( 

437 input_blocks, sample_shape=input_sample_shape, sample_id=None 

438 ) 

439 ) 

440 

441 

442def get_tensor( 

443 src: TensorSource, 

444 descr: Union[ 

445 v0_4.InputTensorDescr, 

446 v0_5.InputTensorDescr, 

447 v0_4.OutputTensorDescr, 

448 v0_5.OutputTensorDescr, 

449 Sequence[AxisInfo], 

450 ], 

451 *, 

452 extra_dims: Literal["raise", "squeeze", "stack", "squeeze_or_stack"] = "squeeze", 

453 missing_dims: Literal["raise", "expand", "unstack", "unstack_or_expand"] = "raise", 

454): 

455 """helper to cast/load various tensor sources 

456 

457 Args: 

458 src: the tensor source to load/cast 

459 descr: the tensor description or a sequence of axis infos 

460 extra_dims: 

461 How to handle extra dimensions in the input tensors. 

462 If "raise", any extra dimensions will raise an error. 

463 If "squeeze", any extra singleton dimensions will be squeezed, non-singleton dimensions will raise an error. 

464 If "stack", any extra dimensions will be stacked to the batch dimension. Such a stacked batch dimension then has a multi-index that can be unstacked using `Tensor.unstack_batch_multi_index()`. 

465 If "squeeze_or_stack", any extra singleton dimensions will be squeezed, non-singleton dimensions will be stacked to the batch dimension. 

466 """ 

467 

468 if isinstance( 

469 descr, 

470 ( 

471 v0_4.InputTensorDescr, 

472 v0_5.InputTensorDescr, 

473 v0_4.OutputTensorDescr, 

474 v0_5.OutputTensorDescr, 

475 ), 

476 ): 

477 axes = get_axes_infos(descr) 

478 else: 

479 axes = descr 

480 

481 if isinstance(src, Tensor): 

482 output_dims = [a.id for a in axes] 

483 return src.transpose( 

484 output_dims, extra_dims=extra_dims, missing_dims=missing_dims 

485 ) 

486 elif isinstance(src, xr.DataArray): 

487 output_dims = [a.id for a in axes] 

488 

489 return Tensor.from_xarray(src).transpose( 

490 axes=output_dims, extra_dims=extra_dims, missing_dims=missing_dims 

491 ) 

492 elif isinstance(src, np.ndarray): 

493 return Tensor.from_numpy(src, dims=axes) 

494 else: 

495 return load_tensor(src, axes=axes) 

496 

497 

498def create_sample_for_model( 

499 model: AnyModelDescr, 

500 *, 

501 stat: Optional[Stat] = None, 

502 sample_id: SampleId = None, 

503 inputs: Union[PerMember[TensorSource], TensorSource], 

504 extra_dims: Literal["raise", "squeeze", "stack", "squeeze_or_stack"] = "stack", 

505) -> Sample: 

506 """Create a sample from a single set of input(s) for a specific bioimage.io model 

507 

508 Args: 

509 model: a bioimage.io model description 

510 stat: dictionary with sample and dataset statistics (may be updated in-place!) 

511 inputs: the input(s) constituting a single sample. 

512 extra_dims: How to handle extra dimensions in the input tensors. 

513 If "raise", any extra dimensions will raise an error. 

514 If "squeeze", any extra singleton dimensions will be squeezed, non-singleton dimensions will raise an error. 

515 If "stack", any extra dimensions will be stacked to the batch dimension. Such a stacked batch dimension then has a multi-index that can be unstacked using `Tensor.unstack_batch_multi_index()`. 

516 If "squeeze_or_stack", any extra singleton dimensions will be squeezed, non-singleton dimensions will be stacked to the batch dimension. 

517 """ 

518 

519 model_inputs = {get_member_id(d): d for d in model.inputs} 

520 if isinstance(inputs, collections.abc.Mapping): 

521 inputs = {MemberId(k): v for k, v in inputs.items()} 

522 elif len(model_inputs) == 1: 

523 inputs = {list(model_inputs)[0]: inputs} 

524 else: 

525 raise TypeError( 

526 f"Expected `inputs` to be a mapping with keys {tuple(model_inputs)}" 

527 ) 

528 

529 if unknown := {k for k in inputs if k not in model_inputs}: 

530 raise ValueError(f"Got unexpected inputs: {unknown}") 

531 

532 if missing := { 

533 k 

534 for k, v in model_inputs.items() 

535 if k not in inputs and not (isinstance(v, v0_5.InputTensorDescr) and v.optional) 

536 }: 

537 raise ValueError(f"Missing non-optional model inputs: {missing}") 

538 

539 return Sample( 

540 members={ 

541 m: get_tensor(inputs[m], ipt, extra_dims=extra_dims) 

542 for m, ipt in model_inputs.items() 

543 if m in inputs 

544 }, 

545 stat={} if stat is None else stat, 

546 id=sample_id, 

547 ) 

548 

549 

550def load_sample_for_model( 

551 *, 

552 model: AnyModelDescr, 

553 paths: PerMember[Path], 

554 axes: Optional[PerMember[Sequence[AxisLike]]] = None, 

555 stat: Optional[Stat] = None, 

556 sample_id: Optional[SampleId] = None, 

557): 

558 """load a single sample from `paths` that can be processed by `model`""" 

559 

560 if axes is None: 

561 axes = {} 

562 

563 # make sure members are keyed by MemberId, not string 

564 paths = {MemberId(k): v for k, v in paths.items()} 

565 axes = {MemberId(k): v for k, v in axes.items()} 

566 

567 model_inputs = {get_member_id(d): d for d in model.inputs} 

568 

569 if unknown := {k for k in paths if k not in model_inputs}: 

570 raise ValueError(f"Got unexpected paths for {unknown}") 

571 

572 if unknown := {k for k in axes if k not in model_inputs}: 

573 raise ValueError(f"Got unexpected axes hints for: {unknown}") 

574 

575 members: Dict[MemberId, Tensor] = {} 

576 for m, p in paths.items(): 

577 if m not in axes: 

578 axes[m] = get_axes_infos(model_inputs[m]) 

579 logger.info( 

580 "loading '{}' from {} with default input axes {} ", 

581 m, 

582 p, 

583 axes[m], 

584 ) 

585 members[m] = load_tensor(p, axes[m]) 

586 

587 return Sample( 

588 members=members, 

589 stat={} if stat is None else stat, 

590 id=sample_id or tuple(sorted(paths.values())), 

591 ) 

592 

593 

594def split_sample_into_blocks_for_model( 

595 sample: Sample, 

596 model: v0_5.ModelDescr, 

597 blocksize_parameter: int, 

598 batch_size: int = 1, 

599) -> Tuple[TotalNumberOfBlocks, Iterable[SampleBlockWithOrigin]]: 

600 if isinstance(model, v0_4.ModelDescr): 

601 raise NotImplementedError( 

602 "`predict_sample_with_blocking` not implemented for v0_4.ModelDescr" 

603 + f" {model.name}." 

604 + " Consider using `predict_sample_with_fixed_blocking` or update the model description to format version 0.5." 

605 ) 

606 

607 ns = { 

608 (ipt.id, a.id): blocksize_parameter 

609 for ipt in model.inputs 

610 for a in ipt.axes 

611 if isinstance(a.size, v0_5.ParameterizedSize) 

612 } 

613 halo = get_input_halo(model) 

614 

615 input_block_shape = model.get_tensor_sizes(ns, batch_size=batch_size).inputs 

616 

617 return sample.split_into_blocks( 

618 block_shapes=input_block_shape, 

619 halo=halo, 

620 pad_mode={ipt.id: ipt.pad or "symmetric" for ipt in model.inputs}, 

621 ) 

622 

623 

624def transpose_sample_for_model( 

625 sample: Sample, 

626 model: AnyModelDescr, 

627 extra_dims: Literal["raise", "squeeze", "stack", "squeeze_or_stack"] = "stack", 

628 missing_dims: Literal["raise", "expand", "unstack", "unstack_or_expand"] = "raise", 

629) -> Sample: 

630 """Transpose sample members to the order expected as inputs by the model. 

631 

632 Unexpected dimensions are stacked to the batch dimension, missing dimensions are added as singletons. 

633 """ 

634 

635 axes = {get_member_id(d): [a.id for a in get_axes_infos(d)] for d in model.inputs} 

636 for m in axes: 

637 if m not in sample.members and not ( 

638 isinstance(model, v0_5.ModelDescr) 

639 and [d for d in model.inputs if get_member_id(d) == m][0].optional 

640 ): 

641 raise ValueError( 

642 f"Sample is missing non-optional member {m} required by model" 

643 ) 

644 

645 return sample.transpose( 

646 axes=axes, 

647 extra_dims=extra_dims, 

648 missing_dims=missing_dims, 

649 )