# Model persistence (`.eigenkit`)

The supported quantum estimators can save a **fitted** model to disk, and load it again later without training it again.

A saved model is a **`.eigenkit` bundle** of JSON, NumPy and PyTorch files. Models that hold scikit-learn objects (`QSVC`, `QSVR`, the reservoir estimators and the time-series estimators) add them as joblib pickles, and loading a pickle runs code. As with `torch.load` or `joblib.load`, **load only bundles that you trust.**

```python
from eigenkit import HQNC, load_model
from eigenkit.datasets import load_dataset

data = load_dataset("iris")
X_train, y_train, X_test = data["X_train"], data["y_train"], data["X_test"]

model = HQNC(in_features=4, n_classes=3, depth=2, epochs=30)
model.fit(X_train, y_train)

# Save (adds .eigenkit if you omit the suffix)
model.save("artifacts/my_hqnc")
loaded = HQNC.load("artifacts/my_hqnc")

# Or load without naming the class
loaded = load_model("artifacts/my_hqnc")
assert (loaded.predict(X_test) == model.predict(X_test)).all()
```

______________________________________________________________________

## The layout of a `.eigenkit` bundle

A typical bundle contains these files:

| Path                | Contents                                                                               |
| ------------------- | -------------------------------------------------------------------------------------- |
| `metadata.json`     | The keys `class_name`, `library_version`, `format_version`, `saved_at` and `provider`. |
| `params.json`       | The hyperparameters of the constructor, in a form that JSON can store.                 |
| `circuit_spec.json` | The list of blocks and the observables, for `CustomQuantum*` only.                     |
| `state/*`           | The fitted state: weights, labels, the training data of kernels and so on.             |

The state files use typed extensions:

| Extension | Meaning                                                                              |
| --------- | ------------------------------------------------------------------------------------ |
| `.pt`     | A `state_dict` saved with `torch.save`, which holds the weights of the model.        |
| `.npy`    | A NumPy array, for example `X_train`, or the joblib bytes of `QSVC` and `QSVR`.      |
| `.json`   | Scalars or lists that JSON can store, for example `train_losses` or `label_classes`. |

PyTorch is imported only when a `.pt` entry is read or written. The `QSVC` and `QSVR` bundles, which do not use PyTorch, have no `.pt` entry, so they can be saved and loaded with the `qml` extra alone, without PyTorch.

Example listing for `HQNC`:

```text
metadata.json
params.json
state/label_classes.json
state/n_classes.json
state/train_losses.json
state/validation_losses.json
state/weights.pt
```

Example for `QSVC`:

```text
metadata.json
params.json
state/X_train.npy
state/svc_joblib.npy    # sklearn SVC bytes (joblib), not a .joblib file in the zip
state/calibrated_joblib.npy    # only with probability=True: the fitted calibration
```

`QSVC` and `QSVR` bundles include joblib bytes

For `QSVC` and `QSVR`, the fitted `sklearn.svm` object is serialised with joblib in memory, and then stored as a `uint8` array in `state/*_joblib.npy`. Joblib uses pickle internally, so load these bundles only from sources that you trust.

`metadata.json` includes `"format_version": 1`. `load()` refuses a bundle whose `format_version` is missing or different, with an error that names the version.

______________________________________________________________________

## `save()` and `load()`

### Saving

```python
model.save("path/to/model")  # writes path/to/model.eigenkit
model.save("path/to/model.eigenkit")  # same
```

Call `save()` only **after** `fit()`. The method collects:

- the hyperparameters, from `get_params()`;
- the `circuit_spec`, for custom lists of blocks;
- the state specific to the algorithm, from `_collect_state()`: the weights, the encoders, `train_losses_`, the training matrices of kernels and so on.

### Loading

```python
# Explicit class
loaded = HQNC.load("path/to/model.eigenkit")

# Override backend at load time (does not change saved weights)
loaded = HQNC.load("path/to/model.eigenkit", provider="qiskit")
```

```python
# Class inferred from metadata.json
from eigenkit import load_model

loaded = load_model("path/to/model.eigenkit")
```

After `load()`, the attributes that `fit()` sets are restored when the bundle holds them. This includes `train_losses_` on `HQNC`, `HQNR`, `RecurrentHQNC`, `RecurrentHQNR` and the `Custom*` estimators.

### Supported estimators

| Class                                                       | Notes                                                                                 |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `HQNC`, `HQNR`                                              | The bundle holds the weights and the training metadata.                               |
| `RecurrentHQNC`, `RecurrentHQNR`                            | The same, and the loaded model expects 3-D input.                                     |
| `QSVC`, `QSVR`                                              | The bundle holds the quantum kernel, the state of the scikit-learn SVM and `X_train`. |
| `CustomQuantumClassifier`, `CustomQuantumRegressor`         | The blocks in `circuit_spec` must be serialisable.                                    |
| `QuantumTimeSeriesClassifier`, `QuantumTimeSeriesRegressor` | The bundle holds the wrapper and the state of the inner estimator.                    |
| `GenericClassifier`, `GenericRegressor`                     | You must pass the architecture yourself when you load. See below.                     |

A `QSVC` or `QSVR` built with `kernel=QuantumKernel(...)` stores that kernel in `params.json`. It stores the name of a built-in map, or a custom map as its `CircuitSpec`, together with its weights, `scale` and `psd_projection`, so a trained kernel is loaded trained. `load(provider=...)` also builds the kernel again on that provider.

#### GenericClassifier and GenericRegressor

The bundle stores the weights, the training metadata and, for `GenericClassifier`, the label encoder. It does **not** store the architecture of the `nn.Module`. You must pass the same architecture when you load:

```python
from eigenkit import GenericClassifier, load_model

# Direct class method
clf_loaded = GenericClassifier.load("clf_hybrid.eigenkit", model=clf_net_fresh)

# Or via load_model, pass model= explicitly
clf_loaded = load_model("clf_hybrid.eigenkit", model=clf_net_fresh)
```

Calling `load_model()` or `.load()` without `model=` raises a `SerializationError`.

**Not estimators:** `optimization.QAOA` and `chemistry.VQE` are solvers that return result objects. They are not fitted scikit-learn estimators, so the `.eigenkit` bundles do not apply to them.

**Custom circuits:** a `CustomBlock` built with `CircuitBuilder` can be saved when it uses the supported gates and parameter expressions of the intermediate representation. Other block objects still need `to_dict()` and `from_dict()`, or must be built-in block types.

______________________________________________________________________

## Base classes for custom algorithms

> **Coming in a future release:** full `.eigenkit` support for saving and loading custom model classes (subclasses of `BaseQuantumClassifier`, `BaseQuantumRegressor` or `BaseQuantumEstimator`) is not available yet. This covers both `save()` and `load_model()` or `.load()` for estimators that you define.

______________________________________________________________________

## Credentials are never saved

Configuration fields marked as secret are left out of `.eigenkit` bundles, and of pickles and joblib checkpoints (including the `.joblib` files that `benchmark.compare()` writes). Today, these fields are `QiskitRuntimeConfig.token` and `IQMConfig.token`. They are also hidden from `repr()`, so the `provider` entry in `metadata.json` does not show them.

After a model is loaded, a secret field takes its default value (`None`), and the backend falls back to its own way of finding credentials. Set the credential in the environment of the process that runs the model: `QISKIT_IBM_TOKEN` for IBM Quantum, and `IQM_TOKEN` for IQM. The worker processes of joblib and loky receive their models as pickles, so they need the variable too. Copies inside one process (`copy.copy`, `copy.deepcopy`, `sklearn.base.clone`, and so `GridSearchCV`) keep the credential.

```python
from eigenkit import QSVC, QiskitRuntimeConfig

model = QSVC(
    feature_map="Angle",
    reps=1,
    provider=QiskitRuntimeConfig(
        backend_name="ibm_fez", primitive="sampler", token="..."
    ),
)
model.fit(X_train, y_train)
model.save("artifacts/qsvc")  # the token is not written

# Later, possibly on another machine: export QISKIT_IBM_TOKEN first.
loaded = QSVC.load("artifacts/qsvc")
assert loaded.provider.token is None  # resolved from the environment instead
```

With `QSVC(kernel=QuantumKernel(..., provider=config))`, the configuration belongs to the kernel, so the same check reads `loaded.kernel.provider.token`.

The fields that decide where a provider connects (`url`, `service_kwargs`, `provider_kwargs` and `backend_instance`) are not saved either. If a bundle holds them anyway, they are ignored, with a warning. Otherwise, a shared bundle could send the token from your environment to a host of its choice. To load a model onto a custom endpoint, pass the full configuration: `QSVC.load(path, provider=QiskitRuntimeConfig(..., service_kwargs=...))`.

______________________________________________________________________

## Benchmark integration

`benchmark.compare()` can save the fitted models when `save_dir` is set:

```python
from eigenkit import HQNC, QSVC
from eigenkit.benchmark import compare
from sklearn.svm import SVC

compare(
    models=[HQNC(in_features=4, depth=2), SVC()],
    dataset="iris",
    n_seeds=2,
    save_dir="benchmark_runs/",
)
```

The output files are named as follows:

| Model type                                      | Filename pattern               |
| ----------------------------------------------- | ------------------------------ |
| An eigenkit estimator (`BaseQuantumEstimator`)  | `{ModelName}_seed{N}.eigenkit` |
| A built-in scikit-learn estimator (`sklearn.*`) | `{ModelName}_seed{N}.joblib`   |

`ModelName` is the benchmark key. When two models have the same class name, they get an index (`SVC_0`, `SVC_1`). Pass `models` as a dictionary to choose the benchmark key:

```python
compare(
    models={
        "Quantum SVC": QSVC(feature_map="Angle", reps=1, probability=True),
        "Regularized SVC": SVC(C=0.3),
        "Flexible SVC": SVC(C=3.0),
    },
    dataset="iris",
    metrics=["accuracy"],
    n_seeds=1,
    save_dir="benchmark_runs/",
)
# benchmark_runs/Quantum SVC_seed0.eigenkit
# benchmark_runs/Regularized SVC_seed0.joblib
# benchmark_runs/Flexible SVC_seed0.joblib
```

Custom benchmark keys follow the naming rules in [Benchmark](https://docs.kit.eigenorbit.com/user-guide/benchmark/index.md).

To load a quantum model saved by a benchmark:

```python
from eigenkit import load_model

model = load_model("benchmark_runs/HQNC_seed0.eigenkit")
```

Load the scikit-learn baselines with `joblib.load("benchmark_runs/SVC_seed0.joblib")`. `eigenkit.benchmark.load_checkpoint(...)` can load either type of file from a benchmark run.

`.joblib` checkpoints use pickle internally, so load them only from sources that you trust.

See [Benchmark](https://docs.kit.eigenorbit.com/user-guide/benchmark/index.md) for the metrics, the seeds and `BenchmarkResult`.

______________________________________________________________________

## API reference

### `eigenkit.qml.load_model(path, *, provider=None, model=None)`

Load any saved model. The class of the model is read from `metadata.json`.

Load only bundles that you trust. A bundle can contain joblib pickles, and unpickling runs code, as with `torch.load` or `joblib.load`.

Parameters:

| Name       | Type           | Description                                                                                                                    | Default                                                                                     |
| ---------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `path`     | \`str          | Path\`                                                                                                                         | The path to the .eigenkit bundle.                                                           |
| `provider` | \`ProviderSpec | None\`                                                                                                                         | A provider that replaces the saved one. It is passed to the load() method of the estimator. |
| `model`    | `Any`          | The nn.Module architecture, for checkpoints of GenericRegressor and GenericClassifier. It is ignored for the other estimators. | `None`                                                                                      |

Returns:

| Type                   | Description                                                         |
| ---------------------- | ------------------------------------------------------------------- |
| `BaseQuantumEstimator` | The loaded estimator, an instance of the class named in the bundle. |

### `eigenkit.qml.BaseQuantumEstimator`

Bases: `BaseEstimator`, `ABC`

Base class, compatible with scikit-learn, for all quantum algorithms.

Parameters:

| Name           | Type           | Description                                                                                                                                                                                                   | Default                                                                                                                                                                                                                                                                                                                                                                  |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `provider`     | `ProviderSpec` | The backend. It can be a registered name ("pennylane", "qiskit", "braket", or any name passed to register()), a configuration instance (for example PennyLaneConfig(device_name=...)), or a backend instance. | `'numpy'`                                                                                                                                                                                                                                                                                                                                                                |
| `random_state` | \`int          | None\`                                                                                                                                                                                                        | The seed for reproducibility, applied inside fit() and load(). What it seeds depends on the estimator. The PyTorch estimators isolate the random number generator of PyTorch, so seeding one of them does not reseed the random stream of the caller. The kernel estimators are deterministic, and they take it only so that all the estimators have the same interface. |

#### `fit(X, y)`

Fit the estimator on `X` and `y`.

Parameters:

| Name | Type      | Description                                        | Default    |
| ---- | --------- | -------------------------------------------------- | ---------- |
| `X`  | `ndarray` | The samples, of shape (n_samples, n_features).     | *required* |
| `y`  | `ndarray` | One target for each sample, of shape (n_samples,). | *required* |

Returns:

| Type                     | Description                                   |
| ------------------------ | --------------------------------------------- |
| `'BaseQuantumEstimator'` | This estimator, so that calls can be chained. |

#### `load(path, *, provider=None)`

Load a fitted model from a `.eigenkit` bundle.

Load only bundles that you trust. A bundle can contain joblib pickles, and unpickling runs code, as with `torch.load` or `joblib.load`.

Parameters:

| Name       | Type           | Description | Default                                 |
| ---------- | -------------- | ----------- | --------------------------------------- |
| `path`     | \`str          | Path\`      | The path of the bundle.                 |
| `provider` | \`ProviderSpec | None\`      | A provider that replaces the saved one. |

Returns:

| Type                     | Description           |
| ------------------------ | --------------------- |
| `'BaseQuantumEstimator'` | The loaded estimator. |

Raises:

| Type        | Description                                                                                                                  |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `TypeError` | If the method is called on a subclass and the bundle holds a different class. BaseQuantumEstimator.load() accepts any class. |

#### `predict(X)`

Predict a value for every sample in `X`.

Parameters:

| Name | Type      | Description                                    | Default    |
| ---- | --------- | ---------------------------------------------- | ---------- |
| `X`  | `ndarray` | The samples, of shape (n_samples, n_features). | *required* |

Returns:

| Type      | Description                     |
| --------- | ------------------------------- |
| `ndarray` | One prediction for each sample. |

#### `save(path)`

Save a fitted model to a `.eigenkit` bundle.

The bundles of models that hold scikit-learn objects (`QSVC`, `QSVR`, the reservoir estimators and the time-series estimators) contain joblib pickles, so loading one can run code. Share them only with people who trust you.

Parameters:

| Name   | Type  | Description | Default                                                                   |
| ------ | ----- | ----------- | ------------------------------------------------------------------------- |
| `path` | \`str | Path\`      | The path of the bundle. The suffix .eigenkit is added when it is missing. |

### `eigenkit.qml._serialization.SerializationError`

Bases: `Exception`

Raised when a model cannot be written to a bundle, or a bundle cannot be read.
