From b35c92068504b0f24a559f61a2c3435566114129 Mon Sep 17 00:00:00 2001 From: Cao Yuji Date: Thu, 13 Aug 2026 04:49:19 +0800 Subject: [PATCH] Add stacked layers, dropout, benchmark, and contributor docs Stacking and regularisation: - num_layers on QLSTM/LQLSTM stacks recurrent layers; each layer reads the hidden-state sequence of the layer below, and h_n/c_n are shaped (num_layers, batch, hidden_size), matching torch.nn.LSTM. - dropout applies between stacked layers (every layer except the last). - The single-layer path and the layer.cell attribute are unchanged, so existing code keeps working. Tests: - Add coverage for stacked shapes, gradient flow to every layer, stacked initial state, the wrong-layer-count error, eval-time dropout determinism, the cell property, and constructor validation. Full suite: 18 passed. Examples: - examples/benchmark_vs_classical.py runs a fair side-by-side against torch.nn.LSTM on the same task and reports parameter counts and losses. Docs and metadata: - README documents stacking and the benchmark; CHANGELOG records the changes. - Add community health files (CONTRIBUTING, issue and pull-request templates). - Add the Python 3.13 packaging classifier, which CI already tests. Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.md | 44 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 25 ++++ .github/PULL_REQUEST_TEMPLATE.md | 21 ++++ CHANGELOG.md | 14 +++ CONTRIBUTING.md | 61 ++++++++++ README.md | 29 ++++- examples/benchmark_vs_classical.py | 90 +++++++++++++++ pyproject.toml | 1 + qlstm/layer.py | 132 ++++++++++++++++------ tests/test_qlstm.py | 59 ++++++++++ 11 files changed, 444 insertions(+), 37 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 examples/benchmark_vs_classical.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3ad7d3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Report a problem so it can be fixed +title: "" +labels: bug +assignees: "" +--- + +## What happened + +A clear description of the problem. + +## How to reproduce + +A minimal script that shows the problem. For example: + +```python +import torch +from qlstm import QLSTM + +layer = QLSTM(input_size=8, hidden_size=4, n_qubits=4) +out, _ = layer(torch.randn(6, 2, 8)) +# ...what goes wrong +``` + +## What you expected + +What should have happened instead. + +## Error output + +The full traceback or wrong result, if any. + +``` +paste here +``` + +## Environment + +- `qlstm` version: +- Python version: +- PyTorch version: +- PennyLane version: +- Operating system: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..685a768 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question or usage help + url: https://github.com/TravisCao/Quantum-LSTM/issues + about: For a question rather than a bug, open an issue or email travisyjcao@gmail.com. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cf53a97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest a new capability or improvement +title: "" +labels: enhancement +assignees: "" +--- + +## Use case + +The problem you want to solve or the workflow you want to support. + +## Proposed feature + +What you would like `qlstm` to do. If it maps to a `torch.nn.LSTM` feature +(for example bidirectionality or a projection size), name it so the interface +can stay compatible. + +## Alternatives + +Other ways you have considered solving this, if any. + +## Additional context + +Links, papers, or examples that help explain the request. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..273ef99 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Summary + +What this pull request changes, and why. + +## Related issue + +Closes # + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Refactor or maintenance + +## Checklist + +- [ ] Added or updated a test for the change +- [ ] `pytest -q` passes locally +- [ ] Kept the public API compatible with `torch.nn.LSTM` where they overlap, or explained why not +- [ ] Updated `README.md` and `CHANGELOG.md` if behaviour or the interface changed diff --git a/CHANGELOG.md b/CHANGELOG.md index 840f799..891de67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Stacked layers: `num_layers` on `QLSTM`/`LQLSTM` stacks recurrent layers, with + `h_n`/`c_n` shaped `(num_layers, batch, hidden_size)`, matching + `torch.nn.LSTM`. +- Inter-layer `dropout` on `QLSTM`/`LQLSTM`, applied to the output of every layer + except the last. +- `examples/benchmark_vs_classical.py`: a fair side-by-side against + `torch.nn.LSTM` on the same task. +- Community health files: `CONTRIBUTING.md`, issue templates, and a pull-request + template. +- Python 3.13 added to the packaging classifiers (already covered by CI). + ## [0.1.0] - 2026-08-13 First packaged release. The quantum LSTM from the paper is now an installable, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c922504 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to qlstm + +Thanks for your interest in `qlstm`. Bug reports, feature requests, and pull +requests are all welcome. + +## Ways to help + +- **Report a bug.** Open an issue with the bug-report template. A short script + that reproduces the problem is the fastest way to a fix. +- **Request a feature.** Open an issue with the feature-request template and + describe the use case. +- **Send a pull request.** Fix a bug, add a test, improve the docs, or add a + feature. For a large change, open an issue first so the design can be agreed + before you write the code. + +## Development setup + +Python 3.10 or newer is required. Install the package and its test dependencies +in editable mode: + +```bash +git clone https://github.com/TravisCao/Quantum-LSTM.git +cd Quantum-LSTM +pip install -e ".[test]" +``` + +This installs PyTorch, PennyLane, and pytest. + +## Run the tests + +```bash +pytest -q +``` + +The suite must pass before a pull request can merge. Continuous integration runs +the same command on Python 3.10 through 3.13, so run it locally first. + +## Pull request checklist + +1. Add or update a test for the behaviour you change. New features need a test + that fails before the change and passes after it. +2. Keep the public API compatible with `torch.nn.LSTM` where the two overlap. A + change that breaks that compatibility needs a clear reason in the pull + request description. +3. Run `pytest -q` and confirm it is green. +4. Update `README.md` and `CHANGELOG.md` if you change behaviour, add a feature, + or change the public interface. +5. Match the style of the surrounding code: type hints on public functions, + docstrings on public classes and methods, and clear names. + +## Scope + +The installable `qlstm` package under [`qlstm/`](qlstm/) targets current PyTorch +and PennyLane. The original paper-reproduction code under [`src/`](src/) targets +the pinned versions in `requirement.txt` and is kept for reference; new library +work belongs in `qlstm/`. + +## Questions + +Open an [issue](https://github.com/TravisCao/Quantum-LSTM/issues) or contact +travisyjcao@gmail.com. diff --git a/README.md b/README.md index 30c56ea..7213a5c 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ print(h_n.shape) # torch.Size([1, 2, 4]) ``` A complete training loop on a small synthetic task is in -[`examples/quickstart.py`](examples/quickstart.py). +[`examples/quickstart.py`](examples/quickstart.py). A fair side-by-side against a +classical `torch.nn.LSTM` on the same task is in +[`examples/benchmark_vs_classical.py`](examples/benchmark_vs_classical.py). ## Two models: `QLSTM` and `LQLSTM` @@ -71,6 +73,26 @@ from qlstm import QLSTM layer = QLSTM(input_size=4, hidden_size=3, n_qubits=7, linear_enhanced=False) ``` +## Stacking layers + +Set `num_layers` to stack recurrent layers, exactly as in `torch.nn.LSTM`. Each +layer reads the hidden-state sequence of the layer below. `output` is the top +layer's sequence, and `h_n`/`c_n` gather the final state of every layer with +shape `(num_layers, batch, hidden_size)`. A non-zero `dropout` applies dropout to +the output of each layer except the last. + +```python +import torch +from qlstm import LQLSTM + +layer = LQLSTM(input_size=8, hidden_size=4, n_qubits=4, num_layers=2, dropout=0.1) + +x = torch.randn(6, 2, 8) +output, (h_n, c_n) = layer(x) +print(output.shape) # torch.Size([6, 2, 4]) +print(h_n.shape) # torch.Size([2, 2, 4]) +``` + ## API | Object | Purpose | @@ -80,12 +102,15 @@ layer = QLSTM(input_size=4, hidden_size=3, n_qubits=7, linear_enhanced=False) | `QLSTMCell` | One recurrent step, for custom loops. | | `make_vqc` | Build the underlying variational circuit as a `torch` layer. | -Key constructor arguments (shared by `QLSTM`, `LQLSTM`, and `QLSTMCell`): +Key constructor arguments (shared by `QLSTM`, `LQLSTM`, and `QLSTMCell`, except +`num_layers` and `dropout`, which apply to the sequence layers only): | Argument | Default | Meaning | | --- | --- | --- | | `n_qubits` | `4` | Wires per gate circuit. | | `n_qlayers` | `1` | Depth of the entangling ansatz. | +| `num_layers` | `1` | Number of stacked recurrent layers (`QLSTM`/`LQLSTM`). | +| `dropout` | `0.0` | Dropout on the output of each layer except the last (`QLSTM`/`LQLSTM`). | | `ansatz` | `"basic"` | `"basic"` ([`BasicEntanglerLayers`](https://docs.pennylane.ai/en/stable/code/api/pennylane.BasicEntanglerLayers.html)) or `"strong"` ([`StronglyEntanglingLayers`](https://docs.pennylane.ai/en/stable/code/api/pennylane.StronglyEntanglingLayers.html), more expressive). | | `rotation` | `"Y"` | Angle-embedding axis (`"X"`, `"Y"`, or `"Z"`). | | `input_activation` | `"arctan"` | Angle activation before embedding; bounds the encoded angles as in the paper. `"tanh"`, `None`, or any callable also work. | diff --git a/examples/benchmark_vs_classical.py b/examples/benchmark_vs_classical.py new file mode 100644 index 0000000..5f1f8a5 --- /dev/null +++ b/examples/benchmark_vs_classical.py @@ -0,0 +1,90 @@ +"""Compare an LQLSTM with a classical ``torch.nn.LSTM`` on a small task. + +The point is a fair, reproducible side-by-side, not a claim that the quantum +layer wins. It trains both models on the same data with the same head and +optimiser settings, then prints each model's trainable-parameter count and its +final training and held-out loss. The task is a genuine recurrent one: predict +the running sum of the first input feature over the sequence, which needs the +cell to accumulate state. + +Run with:: + + python examples/benchmark_vs_classical.py + +Quantum simulation is slow, so the sizes are deliberately small. +""" + +from __future__ import annotations + +import torch +import torch.nn as nn + +from qlstm import LQLSTM + + +def make_data(n, seq_len, input_size, generator): + x = torch.randn(seq_len, n, input_size, generator=generator) + # Target: sum of feature 0 over time -> requires accumulation across steps. + y = x[:, :, 0].sum(dim=0, keepdim=True).transpose(0, 1) # (n, 1) + return x, y + + +def count_params(module): + return sum(p.numel() for p in module.parameters() if p.requires_grad) + + +def train(layer, head, x, y, xv, yv, steps, lr): + params = list(layer.parameters()) + list(head.parameters()) + opt = torch.optim.Adam(params, lr=lr) + loss_fn = nn.MSELoss() + for _ in range(steps): + opt.zero_grad() + out, _ = layer(x) + loss = loss_fn(head(out[-1]), y) + loss.backward() + opt.step() + with torch.no_grad(): + out, _ = layer(x) + train_loss = loss_fn(head(out[-1]), y).item() + outv, _ = layer(xv) + val_loss = loss_fn(head(outv[-1]), yv).item() + return train_loss, val_loss + + +def main(): + torch.manual_seed(0) + gen = torch.Generator().manual_seed(0) + + seq_len, input_size, hidden = 6, 2, 4 + x, y = make_data(64, seq_len, input_size, gen) + xv, yv = make_data(64, seq_len, input_size, gen) + steps, lr = 80, 0.05 + + print(f"task: running-sum regression seq_len={seq_len} input={input_size} " + f"hidden={hidden} train/val=64/64 steps={steps}\n") + + q_layer = LQLSTM(input_size=input_size, hidden_size=hidden, n_qubits=4) + q_head = nn.Linear(hidden, 1) + q_params = count_params(q_layer) + count_params(q_head) + q_train, q_val = train(q_layer, q_head, x, y, xv, yv, steps, lr) + + c_layer = nn.LSTM(input_size=input_size, hidden_size=hidden) + c_head = nn.Linear(hidden, 1) + c_params = count_params(c_layer) + count_params(c_head) + c_train, c_val = train(c_layer, c_head, x, y, xv, yv, steps, lr) + + print(f"{'model':<16}{'params':>10}{'train MSE':>14}{'val MSE':>12}") + print("-" * 52) + print(f"{'LQLSTM':<16}{q_params:>10}{q_train:>14.4f}{q_val:>12.4f}") + print(f"{'nn.LSTM':<16}{c_params:>10}{c_train:>14.4f}{c_val:>12.4f}") + print( + "\nBoth models train on this task and reach a comparable validation " + "error.\nThe linear-enhanced quantum layer carries extra classical " + "projection\nparameters, so it is not smaller than a classical LSTM of " + "this size.\nNumbers vary with seed and size; this is a fair-setup " + "demonstration,\nnot a performance claim." + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index db4070c..f900798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ diff --git a/qlstm/layer.py b/qlstm/layer.py index a51d999..baacdeb 100644 --- a/qlstm/layer.py +++ b/qlstm/layer.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Optional, Tuple +import warnings +from typing import List, Optional, Tuple import torch import torch.nn as nn @@ -14,24 +15,31 @@ class QLSTM(nn.Module): - """A single-layer quantum LSTM. + """A (optionally stacked) quantum LSTM. - The call signature and return values mirror :class:`torch.nn.LSTM` (for one - layer, one direction), so it can be dropped into existing models: + The call signature and return values mirror :class:`torch.nn.LSTM` (one + direction), so it can be dropped into existing models: >>> import torch >>> from qlstm import QLSTM - >>> layer = QLSTM(input_size=8, hidden_size=4, n_qubits=4) + >>> layer = QLSTM(input_size=8, hidden_size=4, n_qubits=4, num_layers=2) >>> x = torch.randn(6, 2, 8) # (seq, batch, input_size) >>> out, (h_n, c_n) = layer(x) >>> out.shape, h_n.shape - (torch.Size([6, 2, 4]), torch.Size([1, 2, 4])) + (torch.Size([6, 2, 4]), torch.Size([2, 2, 4])) + + With ``num_layers > 1`` the layers are stacked: each layer consumes the + hidden-state sequence of the layer below, exactly as in + :class:`torch.nn.LSTM`. ``output`` is the top layer's hidden sequence, and + ``h_n``/``c_n`` are stacked over layers with shape + ``(num_layers, batch, hidden_size)``. Args: input_size: Size of each input vector. hidden_size: Size of the hidden and cell states. n_qubits: Number of wires in each gate circuit. n_qlayers: Depth of the entangling ansatz. + num_layers: Number of stacked recurrent layers (default ``1``). ansatz: Entangling ansatz, ``"basic"`` or ``"strong"``. rotation: Angle-embedding rotation axis. input_activation: Angle activation before embedding (default @@ -39,7 +47,13 @@ class QLSTM(nn.Module): backend: PennyLane device name. diff_method: PennyLane differentiation method. linear_enhanced: Use the linear-enhanced L-QLSTM (default) or the - classic QLSTM. See :class:`~qlstm.cell.QLSTMCell`. + classic QLSTM. See :class:`~qlstm.cell.QLSTMCell`. With + ``linear_enhanced=False`` and ``num_layers > 1`` every layer's + circuit acts on its own concatenation, so ``input_size`` must equal + ``hidden_size`` for the shared ``n_qubits`` to fit every layer. + dropout: If non-zero, apply :class:`torch.nn.Dropout` with this + probability to the output of each layer except the last, as in + :class:`torch.nn.LSTM`. batch_first: If ``True``, inputs and outputs are shaped ``(batch, seq, feature)`` instead of ``(seq, batch, feature)``. """ @@ -50,30 +64,57 @@ def __init__( hidden_size: int, n_qubits: int = 4, n_qlayers: int = 1, + num_layers: int = 1, ansatz: Ansatz = "basic", rotation: Rotation = "Y", input_activation: InputActivation = "arctan", backend: str = "default.qubit", diff_method: str = "backprop", linear_enhanced: bool = True, + dropout: float = 0.0, batch_first: bool = False, ) -> None: super().__init__() + if num_layers < 1: + raise ValueError(f"num_layers must be >= 1, got {num_layers}") + if not 0.0 <= dropout < 1.0: + raise ValueError(f"dropout must be in [0, 1), got {dropout}") + if dropout > 0.0 and num_layers == 1: + warnings.warn( + "dropout is applied between stacked layers, so it has no effect " + "with num_layers=1; set num_layers>1 or dropout=0.0.", + stacklevel=2, + ) self.input_size = input_size self.hidden_size = hidden_size + self.num_layers = num_layers self.batch_first = batch_first - self.cell = QLSTMCell( - input_size=input_size, - hidden_size=hidden_size, - n_qubits=n_qubits, - n_qlayers=n_qlayers, - ansatz=ansatz, - rotation=rotation, - input_activation=input_activation, - backend=backend, - diff_method=diff_method, - linear_enhanced=linear_enhanced, + + self.cells = nn.ModuleList( + QLSTMCell( + input_size=input_size if layer == 0 else hidden_size, + hidden_size=hidden_size, + n_qubits=n_qubits, + n_qlayers=n_qlayers, + ansatz=ansatz, + rotation=rotation, + input_activation=input_activation, + backend=backend, + diff_method=diff_method, + linear_enhanced=linear_enhanced, + ) + for layer in range(num_layers) ) + self.dropout = nn.Dropout(dropout) if dropout > 0.0 else None + + @property + def cell(self) -> QLSTMCell: + """The first (bottom) recurrent cell. + + Kept for convenience and backward compatibility; with ``num_layers > 1`` + use :attr:`cells` to reach the others. + """ + return self.cells[0] def forward( self, x: torch.Tensor, hx: Optional[State] = None @@ -87,27 +128,48 @@ def forward( x = x.transpose(0, 1) seq_len, batch, _ = x.shape - if hx is None: - h_t = x.new_zeros(batch, self.hidden_size) - c_t = x.new_zeros(batch, self.hidden_size) - else: - h_t, c_t = hx - # Accept nn.LSTM-style (num_layers, batch, hidden) states. - if h_t.dim() == 3: - h_t, c_t = h_t[0], c_t[0] - - outputs = [] - for t in range(seq_len): - h_t, c_t = self.cell(x[t], (h_t, c_t)) - outputs.append(h_t) - - output = torch.stack(outputs, dim=0) + h0, c0 = self._init_state(x, batch, hx) + + layer_input = x + h_n: List[torch.Tensor] = [] + c_n: List[torch.Tensor] = [] + for layer, cell in enumerate(self.cells): + h_t, c_t = h0[layer], c0[layer] + outputs = [] + for t in range(seq_len): + h_t, c_t = cell(layer_input[t], (h_t, c_t)) + outputs.append(h_t) + layer_seq = torch.stack(outputs, dim=0) + if self.dropout is not None and layer < self.num_layers - 1: + layer_seq = self.dropout(layer_seq) + layer_input = layer_seq + h_n.append(h_t) + c_n.append(c_t) + + output = layer_input if self.batch_first: output = output.transpose(0, 1) - return output, (h_t.unsqueeze(0), c_t.unsqueeze(0)) + return output, (torch.stack(h_n, dim=0), torch.stack(c_n, dim=0)) + + def _init_state( + self, x: torch.Tensor, batch: int, hx: Optional[State] + ) -> State: + """Return per-layer ``(h0, c0)``, each ``(num_layers, batch, hidden)``.""" + if hx is None: + zeros = x.new_zeros(self.num_layers, batch, self.hidden_size) + return zeros, zeros.clone() + h0, c0 = hx + if h0.dim() == 2: # (batch, hidden): accept only for a single layer + h0, c0 = h0.unsqueeze(0), c0.unsqueeze(0) + if h0.size(0) != self.num_layers: + raise ValueError( + f"initial state has {h0.size(0)} layer(s) but the module has " + f"num_layers={self.num_layers}" + ) + return h0, c0 def extra_repr(self) -> str: - return f"batch_first={self.batch_first}" + return f"num_layers={self.num_layers}, batch_first={self.batch_first}" class LQLSTM(QLSTM): diff --git a/tests/test_qlstm.py b/tests/test_qlstm.py index 1ffdbc1..4491599 100644 --- a/tests/test_qlstm.py +++ b/tests/test_qlstm.py @@ -117,3 +117,62 @@ def step(): for _ in range(40): last = step() assert last < first * 0.5, f"loss did not fall enough: {first:.4f} -> {last:.4f}" + + +def test_stacked_output_and_state_shapes(): + layer = QLSTM(input_size=6, hidden_size=4, n_qubits=4, num_layers=3) + out, (h_n, c_n) = layer(torch.randn(5, 2, 6)) + assert out.shape == (5, 2, 4) # top-layer sequence + assert h_n.shape == (3, 2, 4) # (num_layers, batch, hidden) + assert c_n.shape == (3, 2, 4) + + +def test_stacked_gradients_flow_to_every_layer(): + layer = QLSTM(input_size=4, hidden_size=3, n_qubits=4, num_layers=2) + out, _ = layer(torch.randn(3, 2, 4)) + out.sum().backward() + seen_layers = set() + for name, p in layer.named_parameters(): + assert p.grad is not None, f"no grad for {name}" + assert torch.isfinite(p.grad).all(), f"non-finite grad for {name}" + if name.startswith("cells."): + seen_layers.add(name.split(".")[1]) + assert seen_layers == {"0", "1"}, "both stacked layers must be trained" + + +def test_stacked_accepts_initial_state(): + layer = QLSTM(input_size=4, hidden_size=3, n_qubits=4, num_layers=2) + h0 = torch.randn(2, 2, 3) # (num_layers, batch, hidden) + c0 = torch.randn(2, 2, 3) + out, (h_n, c_n) = layer(torch.randn(3, 2, 4), (h0, c0)) + assert out.shape == (3, 2, 3) + assert h_n.shape == (2, 2, 3) + + +def test_wrong_initial_state_layer_count_raises(): + layer = QLSTM(input_size=4, hidden_size=3, n_qubits=4, num_layers=2) + with pytest.raises(ValueError, match="num_layers"): + layer(torch.randn(3, 2, 4), (torch.randn(1, 2, 3), torch.randn(1, 2, 3))) + + +def test_dropout_is_eval_deterministic(): + layer = QLSTM( + input_size=4, hidden_size=3, n_qubits=4, num_layers=2, dropout=0.5 + ) + x = torch.randn(4, 2, 4) + layer.eval() + a, _ = layer(x) + b, _ = layer(x) + assert torch.allclose(a, b) + + +def test_cell_property_is_first_cell(): + layer = QLSTM(input_size=4, hidden_size=3, n_qubits=4, num_layers=2) + assert layer.cell is layer.cells[0] + + +def test_invalid_num_layers_and_dropout_raise(): + with pytest.raises(ValueError, match="num_layers"): + QLSTM(input_size=4, hidden_size=3, n_qubits=4, num_layers=0) + with pytest.raises(ValueError, match="dropout"): + QLSTM(input_size=4, hidden_size=3, n_qubits=4, dropout=1.0)