Extending HQNN-Forge¶
How to add a dataset loader, an encoding layer or a variational block (ansatz) without breaking the conventions the rest of the library relies on. Each section covers:
- the interface and its conventions,
- the reference implementation to copy,
- the tests a new implementation must have,
- what else has to change alongside it.
The workflow itself (issue first, branch naming, commit style, PR checklist) is in
CONTRIBUTING.md.
A dataset loader¶
Reference: hqnn_forge/data/uci.py (load_taiwanese_bankruptcy and the two other UCI
loaders) and its tests in tests/test_data_uci.py, for a file fetched over HTTPS; this is the
pattern to copy for most new datasets. hqnn_forge/data/credit_card.py
(load_credit_card_fraud, tests in tests/test_data_credit_card.py) shows a download through
an external CLI (the Kaggle client) instead.
Interface and conventions¶
- One public function,
load_<name>(path=None, *, download=False, ...). Every option afterpathis keyword-only. It returns aBinaryDataset(fromhqnn_forge.data; do not define a newNamedTuple) withX(float64, shape(n_samples, n_features), C-contiguous),y(int64, shape(n_samples,), 1 = the positive, minority class) andfeature_names(a tuple with one name per column ofX). - Explicit path, then environment, then default.
pathmay name the file or the directory holding it. A path whose name is not the dataset's file name counts as a directory, because withdownload=Truethe directory often does not exist yet. With nopath, the loader looks under$HQNN_FORGE_DATA, then underdata/raw/; reuseDATA_DIR_ENVandDEFAULT_DIRfromcredit_card.pyrather than spelling them again. - Nothing is fetched unless asked. A missing file raises
DatasetNotFoundError(fromhqnn_forge.data). Its message gives the exact command or URL that fetches the file and mentionsdownload=True. Ifdownload=Trueand fetching fails (tool missing, command failed, nothing produced), the loader raisesDatasetDownloadError. It must not raiseDatasetNotFoundErrorthere, so that a caller who catches the first error and retries withdownload=Truecannot loop. - Check what was downloaded. Record the published file's SHA-256 and compare the
downloaded bytes against it before writing anything; a mismatch is a
DatasetDownloadErrorthat says nothing was written. Bound the request with a timeout (DOWNLOAD_TIMEOUT), and write to a.partfile that is renamed into place, so an interrupted download never leaves a truncated file for the next load to find._downloadinuci.pydoes all three. - Validate the schema before trusting the numbers. Check the header against the published
column names and report the first differences. Reject a header-only file, a non-numeric value,
and labels outside {0, 1}, each with a
ValueErrorthat names the file.strict=Trueadditionally requires the published file (its SHA-256, where one is recorded) and checks the published row and positive counts, so a truncated or re-sampled copy is caught. - NumPy only. The core package does not depend on pandas; parse with
np.loadtxtor the standard library. - Leakage-prone columns are flags, not silent drops. An example is
drop_time. The default returns the published columns unchanged. The exception is a column that encodes the label itself (the other examinations inload_cervical_cancer_risk): drop it always, and name it in the docstring. - No preprocessing. Scaling, PCA and resampling belong to
hqnn_forge.preprocessing, applied inside each cross-validation fold. A loader that standardised the data would leak test-fold statistics into training.
Tests (tests/test_data_<name>.py)¶
The dataset itself is never downloaded in CI. Tests write a small file with the real header to
tmp_path and monkeypatch the download (urllib.request.urlopen, or shutil.which and
subprocess.run for a CLI). A loader in uci.py joins the LOADERS list in
tests/test_data_uci.py, which runs the location, strict and download tests
(TestEveryLoader) against it. At minimum:
- arrays match the file: values, dtypes (float64 / int64), shapes,
feature_names, and the positive rate of the fixture; - file path, directory path,
$HQNN_FORGE_DATAand the default location all resolve; - a missing file raises
DatasetNotFoundError, and the message contains the fetch command; - a download that fails, returns something other than the expected file, or has the wrong
checksum raises
DatasetDownloadErrorand writes nothing; a download that succeeds is loaded; - wrong column count, a renamed column, header only, a non-numeric value and a non-binary label
each raise
ValueError; strict=Trueaccepts the published file and counts and rejects others (monkeypatch the expected checksum and counts so a small fixture can pass).
Also update¶
hqnn_forge/data/__init__.py: the import,__all__, and the table in the module docstring.- The module docstring of the file the loader lives in:
uci.pykeeps a table of rows, features and positives, and states the licence and the DOI to cite (say so if the file may not be redistributed). Also thedata/line in the README's folder structure. - Default to a location under
data/raw/, which.gitignoreexcludes. Never commit a dataset, even a small one; tests build their own fixture files.
An encoding layer¶
Reference: IQPEncodingLayer in hqnn_forge/encoding/iqp_embedding.py, the shortest complete
encoder. AmplitudeEncodingLayer shows a prepare_inputs that transforms its input, not just
checks it.
Interface and conventions¶
An encoding layer is an nn.Module that satisfies hqnn_forge.encoding.EncodingLayer. The
contract is spelled out in hqnn_forge/_encoding_contract.py:
qlayer: aqml.qnn.TorchLayer. The QNode's first argument is namedinputs; every other argument is a trainable weight declared inweight_shapes.n_qubits: anint, the circuit's width.n_features: anint, the input's width, meaning the number of features per sample thatprepare_inputsaccepts. It isn_qubitsfor the angle-type layers and up to2**n_qubitsfor the amplitude layer. The diagnostics (circuit_summary,gradient_variance) size their sample inputs by it, andis_encoding_layer(and with it the kernels, such asquantum_kernel_matrix) refuses a layer without it.prepare_inputs(x): the whole classical step between the batch and the QNode, meaning validation (callcheck_inputs(x, self.n_features, name="n_features")fromhqnn_forge.encoding._common, which rejects the wrong width and NaN/inf) and any transform.forward(x)must be exactlyself._run_circuit(self.prepare_inputs(x)), which isself.qlayer(self.prepare_inputs(x))but for noise (below). The kernels (hqnn_forge.kernels) replay the circuit onprepare_inputs(X), so a step done inline inforwardis silently skipped there, and the kernel describes a different feature map without raising.
The one allowed exception is noise. A layer built with noise_level > 0 runs a noisy circuit in
train() mode, and one built with readout_error=(p01, p10) passes the circuit's output
through that readout error in train() mode; an open apply_readout_error block does the
same in either mode. Inherit TrainingNoiseMixin from hqnn_forge.noise, take noise_level,
noise_position, noise_method, noise_trajectories, noise_channel and readout_error in
the constructor, call self._init_training_noise(...) once the QNode exists, run the circuit
through self._run_circuit, and append self._noise_repr() to extra_repr, as every encoding
layer does. The readout error is applied in _run_circuit, on the output: a layer without the
mixin is refused by apply_readout_error (and by noise_sweep(..., channel="readout")), a
forward that calls self.qlayer directly would skip it without any error, and the kernels,
which replay qlayer(prepare_inputs(x)), do not see it. It also assumes every output is a
single-qubit ⟨Z⟩ readout, as measure_z gives: on exact values it maps each one, and on a
sampled layer it reads each output as the count of zeros among the shots and flips those shots,
so an output that is not a single-qubit ⟨Z⟩ estimate (⟨X⟩, a product of Z's, a post-processed
value) gets a wrong result, with no error.
Beyond the protocol:
- Build the QNode through the shared helpers in
hqnn_forge/encoding/_common.py: resolve_backend(device_name, diff_method, n_qubits, shots=shots)turns the default"auto"into concrete names; call it first, since"auto"is not a PennyLane device.resolve_device(device_name, n_qubits, seed=seed)provides the fallback chain todefault.qubitand seeds the device's shot sampling. Take aseed=Noneargument, pass it through, setself.seed = validate_seed(seed), and show it withshots_repr(self.shots, self.seed)inextra_repr, as the built-in layers do; without it a shot-based model built withseed=silently does not repeat.expand_batch_dimension(qnode, diff_method)makes a batchedinputswork under adjoint.apply_variational_layersandmeasure_zprovide the ansatz and the readout.validate_circuit_optionsrejects a bad option at construction, not at the first forward.- Name the angle tensor
weights, with dim 0 indexing layers.gradient_varianceand the Fisher diagnostics find the rotation angles by that name (or as the only tensor), and the initialisers draw per layer along dim 0. Exposen_layersas an attribute. Setn_outputsto the readout width;disable_quantum_layerfills that many outputs. - State the input range in the class docstring. By default the models feed
tanh(·)·π ∈ (−π, π)to the encoder. An encoding that expects something else, such as the IQP phasesx_i x_j, must say so, and the model docstrings must say what they feed it. - Do not transform the QNode. The kernel replay reads the untransformed tape. A compile pass
or a noise transform on the QNode makes
quantum_kernel_matrixraise rather than return the kernel of a different circuit.
Tests¶
- Register the layer in
ENCODERSintests/test_encoding_contract.py. That runs the contract tests: mypy checks the class against the protocol,is_encoding_layermust accept it,prepare_inputsmust acceptn_featuresinputs and reject one more or one fewer asforwarddoes,forward(x)must equalqlayer(prepare_inputs(x))bit for bit, andprepare_inputsmust reject whatforwardrejects. Include a configuration that exercises any transform inprepare_inputs. Build it onN_QUBITSqubits; if itsn_featuresis notn_qubits, extend the expected width intest_satisfies_the_runtime_check. - Add it to
ALL_LAYERSintests/test_kernels.py. That runs the kernel tests (symmetry, PSD, unit diagonal, rejection of bad inputs) and checks that the replayed states reproduce the layer's ownforward. - A module of its own,
tests/test_<name>_embedding.py, which must check: - the output shape and the ⟨Z⟩ range;
- gradients reaching every weight;
- batched output equal to per-sample output;
- input validation;
- the circuit against a hand-written or template version, compared numerically on fixed inputs, not only by shape.
- If the layer can sit in a model, extend the parametrisations in
tests/test_circuit_options.pyandtests/test_circuit_summary.pythat list encoder classes.
Also update¶
hqnn_forge/encoding/__init__.py: the import,__all__, and the table in the docstring.- The list of accepted layers in the
TypeErrormessage ofresolve_encoding_layer(hqnn_forge/_resolve.py). - If the models should offer it, the
encoding_typebranches inhqnn_forge/models/, theirget_configround trip (checkpoints store the config) and the model docstrings. - The README feature table and folder layout.
A variational block (ansatz)¶
Reference: apply_variational_layers in hqnn_forge/encoding/_common.py. The
"hardware_efficient" branch, the most recent addition, is the example to copy: a primitive in
hqnn_forge/circuits/ applied once per layer, with a weight shape of its own. "brickwork"
shows a block written inline, and "strongly_entangling" one that depends on the layer index.
The functions in hqnn_forge/circuits/ are the per-layer blocks the encoders run:
strongly_entangling_layer is entangler="ring" (not "strongly_entangling", which is
PennyLane's qml.StronglyEntanglingLayers) and hardware_efficient_layer is
entangler="hardware_efficient". The block the encoders use is selected by their entangler
argument, so a new ansatz is a new primitive there plus a new entangler value; a primitive
alone has no effect on the models.
Interface and conventions¶
- Signature: a block is applied as
apply_variational_layers(weights, n_qubits, n_layers, entangler, layer_offset)inside the QNode, and records gates only. - Weight shape.
variational_weight_shape(entangler, n_qubits, n_layers), next toapply_variational_layers, is the one definition of theweightsshape: every encoder registers itsweightsfrom it, andextra_reprcountsn_paramsfrom the layer's parameters. TheRotblocks take(n_layers, n_qubits, 3)and"hardware_efficient"takes(n_layers, n_qubits); a block with another shape adds a branch there and nowhere else. Within the shape, dim 0 must stay the layer index, which is whatblock_local_init_and the diagnostics'n_layersfallback read. layer_offset.DataReuploadingLayerapplies the blocks one at a time, with an embedding in between. A block whose gates depend on the layer index (as the strongly-entangling ranges do) must uselayer_offset + ℓ, so that one-at-a-time and all-at-once give the same circuit.- Small circuits. Define the block on
n_qubits = 1or refuse it invalidate_circuit_options(the strongly-entangling branch shows the one-qubit case). - Inert parameters. A rotation that commutes with everything after it up to a Z readout
never changes a prediction.
circuit_summaryreports these asn_inert_params. Check the count for the new block and, if it is not zero, say so in the docstring.
Tests (tests/test_circuit_options.py, class TestEntangler)¶
- The block matches a hand-written gate sequence or a PennyLane template, compared on the state
or the expectation values for fixed weights.
"brickwork"has a test class of its own next toTestEntangler; do the same. - Which features each readout sees after one and two layers: add rows for the new value to the
parametrisation of
TestReadoutFeatures.test_features_each_readout_seesintests/test_circuit_options.py. - Applying layers one by one with
layer_offsetgives the same circuit as all at once. - One qubit, and the smallest
n_qubitsit accepts. - Gradients reach every weight.
- The gate and parameter counts that
circuit_summaryreports. - An unknown
entanglervalue still raises.
Also update¶
- The
EntanglerLiteraland a branch inapply_variational_layers(with its docstring entry), both in_common.py, and a branch invariational_weight_shapeif the block's shape differs.validate_circuit_optionsand its error message read the choices from theLiteral, so they need no change. - The
entanglerparameter docstrings: the angle, IQP and re-uploading builders and layers, andhqnn_forge/models/(HybridBinaryClassifier,ParallelHybridClassifier). - The README feature table, if it is offered as a model option.