## `eigenkit.core.backends.numpy_backend`

The reference NumPy statevector simulator, which is the default backend.

This backend uses only NumPy. It needs neither PyTorch nor an external quantum software development kit. It exists for two reasons:

1. **It works out of the box.** The library runs (`Circuit(provider="numpy")`) without installing a large simulator or one that needs a licence. This backend is always available.
1. **It is the reference for correctness.** Every other backend is tested against it (and against PennyLane) for equal results, so it also serves as the reference that the others are checked against.

It is written to be easy to read, not tuned for speed. For speed, install one of the optional backends. It does use two simple NumPy optimisations. A batch of inputs evolves in one vectorised pass, with a leading batch axis on the state tensor, instead of a Python loop over the samples. A controlled-NOT (CNOT) gate is applied by swapping amplitudes (`np.flip` on the subspace where the control is `1`) instead of by contracting a `4x4` matrix.

#### State representation

The state is a `complex128` tensor of shape `(2,) * n_qubits`, and qubit `q` is on axis `q`. In C order, this makes qubit `0` the most significant bit, as in PennyLane, where wire `0` is the most significant bit. As a result, `state.reshape(-1)[0]` is the amplitude of `|0…0⟩`, and the computed values agree with PennyLane to machine precision.

#### Gradients

The backend declares `gradient_method = "adjoint"` and implements NumpyCompiled.jacobian(). This method computes analytic gradients in reverse mode, with one forward pass and one backward pass, so the number of passes is `O(1)` in the number of parameters. The gradient code of the core package uses it in preference to the parameter-shift rule, which is the fallback, and it differentiates a 2-D `inputs` batch in one batched pass. The gradients agree with the parameter-shift rule and with PennyLane to machine precision.

Example

```python
from eigenkit import Circuit, Encoding, Variational, all_z

blocks = [Encoding(in_features=4), Variational(depth=2)]

# by registered name
circuit = Circuit(blocks=blocks, observables=all_z(4), provider="numpy")

# or with the config, when a device setting matters
from eigenkit.configs import NumpyConfig

circuit = Circuit(
    blocks=blocks,
    observables=all_z(4),
    provider=NumpyConfig(),
)
```

This backend is always available. It is the default, and it needs no extra.

### `NumpyCompiled`

Bases: `CompiledCircuit`

A circuit prepared for the reference NumPy simulator.

NumpySimulator.compile() builds it, and it is cheap to call many times. `run()` evaluates the observables, and `jacobian()` computes their analytic gradient. `statevector()`, `probabilities()` and `zero_state_probability()` read the state directly, which the Harrow-Hassidim-Lloyd (HHL) linear solver needs.

#### `__init__(circuit, observables, config)`

Prepare the circuit for repeated evaluation.

`NumpySimulator.compile()` builds this object, so there is no reason to build one directly.

Parameters:

| Name          | Type                   | Description                 | Default    |
| ------------- | ---------------------- | --------------------------- | ---------- |
| `circuit`     | `CircuitSpec`          | The circuit to prepare.     | *required* |
| `observables` | `Sequence[Observable]` | The observables to measure. | *required* |
| `config`      | `NumpyConfig`          | The device settings.        | *required* |

#### `jacobian(weights, inputs, *, need_input_grad=False)`

Compute the gradient with this backend's own method, the analytic adjoint.

The gradient code of the core package looks for a `jacobian` method. When a backend has one, it is used in preference to the parameter-shift rule, which works on any backend and is the fallback. Here it is the adjoint method in reverse mode: one forward pass and one backward pass, so the number of passes is `O(1)` in the number of parameters.

For a single sample (1-D `inputs`), the two jacobians have shapes `(n_obs, n_weights)` and `(n_obs, n_inputs)`. For a 2-D `inputs` batch, they have a leading batch axis (`(B, n_obs, n_weights)` and `(B, n_obs, n_inputs)`), and the whole batch is differentiated in one pass. This is what makes the training of a hybrid quantum neural network with PyTorch fast on this backend. The gradients with respect to the gate angles are mapped onto the weights and inputs with the same symbolic chain-rule factors as the parameter-shift code, so angles built with `Scale`, `Multiply`, `Sum` or `Negate` are also differentiated correctly.

Parameters:

| Name              | Type      | Description                                                                    | Default    |
| ----------------- | --------- | ------------------------------------------------------------------------------ | ---------- |
| `weights`         | `ndarray` | The parameter vector (1-D).                                                    | *required* |
| `inputs`          | `ndarray` | The input features: 1-D for one sample, or 2-D with a leading batch axis.      | *required* |
| `need_input_grad` | `bool`    | If True, also compute the jacobian with respect to the inputs. Default: False. | `False`    |

Returns:

| Name              | Type      | Description                               |
| ----------------- | --------- | ----------------------------------------- |
| `weight_jacobian` | `ndarray` | The jacobian with respect to the weights. |
| `input_jacobian`  | \`ndarray | None\`                                    |

#### `probabilities(weights, inputs)`

Return the measurement probabilities of all `2**n_qubits` basis states.

Qubit `0` is the most significant bit, as in PennyLane's `qml.probs`. The probabilities are used to decode samples from the prepared state, for example the bitstrings of the Quantum Approximate Optimization Algorithm (QAOA).

Parameters:

| Name      | Type      | Description                             | Default    |
| --------- | --------- | --------------------------------------- | ---------- |
| `weights` | `ndarray` | The parameter vector (1-D).             | *required* |
| `inputs`  | `ndarray` | The input features of one sample (1-D). | *required* |

Returns:

| Type      | Description                                   |
| --------- | --------------------------------------------- |
| `ndarray` | The probabilities, of shape (2\*\*n_qubits,). |

#### `run(weights, inputs)`

Evaluate the circuit and return one expectation value per observable.

It follows the `CompiledCircuit` contract: the result has shape `(n_obs,)` when `weights` and `inputs` are both 1-D, and `(B, n_obs)` otherwise.

Parameters:

| Name      | Type      | Description                                                                 | Default    |
| --------- | --------- | --------------------------------------------------------------------------- | ---------- |
| `weights` | `ndarray` | The parameter vector: 1-D for one sample, or 2-D with a leading batch axis. | *required* |
| `inputs`  | `ndarray` | The input features, with the same 1-D or 2-D shape convention as weights.   | *required* |

Returns:

| Type      | Description                                      |
| --------- | ------------------------------------------------ |
| `ndarray` | The expectation values, one for each observable. |

#### `statevector(weights, inputs, initial_state=None)`

Return the full complex statevector after the circuit has run.

The result is a flat array of `2**n_qubits` amplitudes, with qubit `0` as the most significant bit (the same order as `probabilities()`). `initial_state`, a normalised flat vector in the same order, makes the evolution start from that state instead of `|0…0⟩`. Reading a state out and passing it back in this way is what quantum reservoir carry-over and amplitude encoding are built on. A 2-D `inputs` batch evolves in one pass and returns an array of shape `(B, 2**n_qubits)`. `initial_state` can then be one state of shape `(2**n,)`, shared by all samples, or a stack of shape `(B, 2**n)` with one state for each sample.

Parameters:

| Name            | Type      | Description                                                               | Default                             |
| --------------- | --------- | ------------------------------------------------------------------------- | ----------------------------------- |
| `weights`       | `ndarray` | The parameter vector (1-D).                                               | *required*                          |
| `inputs`        | `ndarray` | The input features: 1-D for one sample, or 2-D with a leading batch axis. | *required*                          |
| `initial_state` | \`ndarray | None\`                                                                    | The state to start from, instead of |

Returns:

| Type      | Description                                                                    |
| --------- | ------------------------------------------------------------------------------ |
| `ndarray` | The statevector, of shape (2\*\*n_qubits,), or (B, 2\*\*n_qubits) for a batch. |

#### `zero_state_probabilities(weights, inputs)`

Return the probability of `|0…0⟩` for every input of a batch, vectorised.

`inputs` has shape `(B, n_inputs)`, and the result has shape `(B,)`. The whole batch evolves in one pass. The quantum kernel uses this method to build a fidelity matrix without a Python loop over pairs.

Parameters:

| Name      | Type      | Description                                  | Default    |
| --------- | --------- | -------------------------------------------- | ---------- |
| `weights` | `ndarray` | The parameter vector (1-D).                  | *required* |
| `inputs`  | `ndarray` | The batch of inputs, of shape (B, n_inputs). | *required* |

Returns:

| Type      | Description                                         |
| --------- | --------------------------------------------------- |
| `ndarray` | The probabilities, one for each row, of shape (B,). |

#### `zero_state_probability(weights, inputs)`

Return the probability of measuring the state `|0…0⟩`.

Fidelity kernels and swap tests are built on it. It does not go through `Observable`, because it is not the expectation value of a Pauli operator.

Parameters:

| Name      | Type      | Description                             | Default    |
| --------- | --------- | --------------------------------------- | ---------- |
| `weights` | `ndarray` | The parameter vector (1-D).             | *required* |
| `inputs`  | `ndarray` | The input features of one sample (1-D). | *required* |

Returns:

| Type    | Description                         |
| ------- | ----------------------------------- |
| `float` | The probability, a float in [0, 1]. |

### `NumpyConfig`

Configuration for the reference NumPy simulator.

It has no settings yet. It exists so that `Circuit(provider=NumpyConfig())` works in the same way as with the configurations of the other backends.

#### `make_backend()`

Build the backend that this configuration describes.

The provider layer calls it when a configuration is passed as `provider=`. There is no reason to call it directly.

Returns:

| Type               | Description                                |
| ------------------ | ------------------------------------------ |
| `'NumpySimulator'` | The NumpySimulator for this configuration. |

### `NumpySimulator`

The provider for the reference NumPy statevector simulator.

It is registered automatically as `"numpy"`. It uses only NumPy and no optional dependency, so it is always available.

It declares `gradient_method = "adjoint"`, so the gradient code of the core package uses NumpyCompiled.jacobian() (analytic, in reverse mode, with one forward pass and one backward pass whatever the number of parameters) instead of the parameter-shift rule, whose cost is `O(n_params)`.

#### `capabilities`

What this backend supports.

It runs every gate of the library, evaluates a batch of inputs in one call, returns exact expectation values only (it does not sample shots), and has no limit on the number of qubits. See Capabilities.

#### `__init__(config=None, name='numpy')`

Build the backend from a device configuration.

Parameters:

| Name     | Type          | Description                                                   | Default   |
| -------- | ------------- | ------------------------------------------------------------- | --------- |
| `config` | `NumpyConfig` | The device settings. Default: None, which uses NumpyConfig(). | `None`    |
| `name`   | `str`         | The registry name, used in error messages. Default: "numpy".  | `'numpy'` |

#### `compile(circuit, observables)`

Prepare `circuit` for repeated evaluation of `observables`.

A `Circuit` calls it once and reuses the result for every call, so the expensive work belongs here.

Parameters:

| Name          | Type                   | Description                 | Default    |
| ------------- | ---------------------- | --------------------------- | ---------- |
| `circuit`     | `CircuitSpec`          | The circuit to prepare.     | *required* |
| `observables` | `Sequence[Observable]` | The observables to measure. | *required* |

Returns:

| Type            | Description                                      |
| --------------- | ------------------------------------------------ |
| `NumpyCompiled` | The prepared circuit, as a NumpyCompiled object. |
