diff --git a/README.md b/README.md index a0e94f3..ec3aea4 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,93 @@ # PyQit -## Current Plan (sub to change any time >_<) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Status: Active Development](https://img.shields.io/badge/status-active_development-orange.svg)]() +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Build Status](https://img.shields.io/github/actions/workflow/status/phoeenniixx/pyQit/test.yml)](https://github.com/phoeenniixx/pyqit/actions) -- ansatz -> `QuantumPipeline` wrapper that connects the ansatz and DL model (if any) -> `Trainer` (with/w/o `torch`) -- Data preprocessing techniques (no idea how to do that rn) - maybe using a `lightning` type of data module (thanks a lot `lightning`). But as `lightning` has a core dep of `torch`, I have to reinvent(?) it ig -- any ansatz could go with any DL backbone (ideally, not sure how much is feasible - tbd) -- `torch` is used with `lightning` as a imp soft-dep now!! +> **A high-level quantum machine learning framework built on PennyLane.** +> It aims to make quantum machine learning more accessible by reducing the steep learning curve, streamlining the boilerplate code required for training, and providing mathematically rigorous diagnostics. -*Will add better vignettes once i have my ideas consolidated in my mind* -> ***Have a look at a basic tutorial [here](https://github.com/phoeenniixx/pyQit/tree/main/doc/tutorials/vqc.ipynb) (This is how the flow would look like in future, still working on making data module and Trainer etc more "user friendly" and expressive)*** +## The Philosophy -for some idea how it might look like: -```python -# no torch -pyqit.set_backend("pennylane") -qml_model= QMLmodel(...) # may use their own ansatz? -dm = DataModule(...) -trainer = Trainer(...) -trainer.fit(qml_model, dm) -trainer.predict(qml_model, dm_new, return_format = "numpy") # or "torch" for torch tensors if torch is backend, -# should i add pennylane tensors as well? good question! +Scaling Quantum Machine Learning (QML) research from toy models to enterprise hybrid pipelines is painful. Researchers spend hours rewriting training loops, debugging shape mismatches, and blindly waiting for models to train, only to realize their deep circuit hit a Barren Plateau at epoch 1. + +**PyQit** abstracts away the infrastructure so you can focus on the science. + +### Key Features +* **Lightweight & Modular:** PyQit runs natively on **PennyLane** and **NumPy**. + > **PyTorch and PyTorch Lightning are strictly optional soft dependencies.** If you don't need deep learning hybrid models or GPU orchestration, you don't have to install them. +* **Backend Agnostic**: Seamlessly switch between native `pennylane` (pure Autograd) and `torch` (Lightning engine) with a single parameter. +* **Automated Diagnostics**: Features a mathematical "Pre-Flight Check" that runs Monte Carlo gradient sampling to detect Barren Plateaus mathematically *before* you waste compute time. +* **Enterprise Data Orchestration**: A stateful `DataModule` handles classical normalization (`minmax`, `zscore`) safely and separately from stateless quantum embedding projections (`Amplitude`, `Angle`). + + +## Installation + +PyQit is currently in active development and is installed directly from source via `pyproject.toml`. + +```bash +# 1. Clone the repository +git clone [https://github.com/yourusername/pyqit.git](https://github.com/yourusername/pyqit.git) +cd pyqit + +# 2. Base Installation (PennyLane native, NO PyTorch required) +pip install -e . + +# 3. Optional Installs +# If you want PyTorch and PyTorch Lightning support: +pip install -e .[pytorch] + +# If you want the full suite (PyTorch, Matplotlib for diagnostics, Rich for terminal tables): +pip install -e .[all_extras] + +# For contributors (pytest, sphinx, ruff, etc.): +pip install -e .[dev] ``` -### Using Pipeline + +## Quickstart +Here is how you build a Variational Quantum Classifier (VQC) and run an automated Barren Plateau diagnostic in under 20 lines of code: + ```python -pyqit.set_backend("torch") -dm = DataModule(...) -model_a = QMLmodel(**params) -model_b = QMLmodel(**params) # or DLModel for that matter -trainer = Trainer( max_epochs=10, learning_rate=0.01) -pipeline = QuantumPipeline( - [ - PipelineStage(model_a, name="stage_1", trainable=trainable_a), - PipelineStage(model_b, name="stage_2", trainable=True), - ], - mode="sequential", - ) -pipeline.fit(datamodule=dm, trainers=trainer, fit_mode="sequential_greedy") -preds = pipeline.predict(X_new, batch_size=8) +from sklearn.datasets import make_moons +import numpy as np + +import pyqit +from pyqit.ansatzes.sel import SELAnsatz +from pyqit.core.embeddings import AngleEmbedding +from pyqit.models.classification.vqc import VQCClassifier +from pyqit.data.datamodule import DataModule +from pyqit.core.trainer import Trainer + +# 1. Prepare Data +X, y = make_moons(n_samples=200, noise=0.1) +# DataModule handle stateful normalization securely +dm = DataModule(X=X, y=y, normalize="minmax", batch_size=16) + +# 2. Define Model +model = VQCClassifier( + n_qubits=4, + n_layers=3, + n_classes=2, + ansatz=SELAnsatz, + encoder=AngleEmbedding +) + +# 3. Initialize Trainer with Barren Plateau Pre-Flight Check +trainer = Trainer( + max_epochs=10, + check_bp=True, # Automatically diagnoses gradient variance before training + bp_samples=100, + verbose=1 +) + +# 4. Fit +# Will print a diagnostic table evaluating your circuit against the McClean et al. baseline +history = trainer.fit(model, datamodule=dm) ``` -You can also train just QMLmodel using `Trainer` -here `anyQMLmodel` and `DLmodel` can be implemented by the user themselves or use the implemented ones from the package -Then package would also have a complete model zoo. + +> #### Have a look some tutorials [here](https://github.com/phoeenniixx/pyQit/tree/main/doc/tutorials/) for more info! + +## Contributing +Contributions, issues, and feature requests are welcome! Feel free to check the [issues page](https://github.com/phoeenniixx/pyQit/issues). If you are building novel ansatzes, custom embeddings, or new diagnostic tools, please submit a PR. diff --git a/pyqit/ansatzes/__init__.py b/pyqit/ansatzes/__init__.py index e69de29..e8de74b 100644 --- a/pyqit/ansatzes/__init__.py +++ b/pyqit/ansatzes/__init__.py @@ -0,0 +1,6 @@ +"""A module for quantum ansatzes.""" + +from pyqit.ansatzes.base import BaseAnsatz +from pyqit.ansatzes.sel import SELAnsatz + +__all__ = ["BaseAnsatz", "SELAnsatz"] diff --git a/pyqit/ansatzes/sel.py b/pyqit/ansatzes/sel.py index cdc65f6..948d920 100644 --- a/pyqit/ansatzes/sel.py +++ b/pyqit/ansatzes/sel.py @@ -4,17 +4,60 @@ class SELAnsatz(BaseAnsatz): + """ + Strongly Entangling Layers (SEL) ansatz. + + This class implements a parameterized quantum circuit using PennyLane's + `StronglyEntanglingLayers` template. It applies single-qubit rotations + and entangling gates across the specified number of layers. + + Parameters + ---------- + n_qubits : int + The number of qubits the ansatz acts upon. + n_layers : int, optional + The number of entangling layers in the circuit. Default is 2. + """ + def __init__(self, n_qubits: int, n_layers: int = 2): super().__init__(n_qubits, n_layers) def build_circuit(self, weights): + """ + Construct and apply the strongly entangling layers to the quantum circuit. + + Parameters + ---------- + weights : dict + A dictionary containing the parameter tensors. Must include the + key `"weights"` with a tensor of shape `(n_layers, n_qubits, 3)`. + + """ w_tensor = weights["weights"] qml.templates.StronglyEntanglingLayers(w_tensor, wires=range(self.n_qubits)) def get_weight_shapes(self) -> dict: + """ + Get the shapes of the trainable weights required by the ansatz. + + Returns + ------- + dict + A dictionary mapping the weight parameter name (`"weights"`) to + its expected shape tuple `(n_layers, n_qubits, 3)`. + """ shape = (self.n_layers, self.n_qubits, 3) return {"weights": shape} @classmethod def get_test_params(cls): + """ + Retrieve a set of default parameters for testing the ansatz. + + Returns + ------- + list of dict + A list containing a dictionary of valid initialization parameters + for the class. + """ return [{"n_qubits": 3, "n_layers": 2}] diff --git a/pyqit/base/__init__.py b/pyqit/base/__init__.py index bda56e1..7553b3b 100644 --- a/pyqit/base/__init__.py +++ b/pyqit/base/__init__.py @@ -1 +1,3 @@ +"""Base module for PyQit.""" + from pyqit.base.base_object import _PyQitObject, all_objects diff --git a/pyqit/core/__init__.py b/pyqit/core/__init__.py index e69de29..79c2ea3 100644 --- a/pyqit/core/__init__.py +++ b/pyqit/core/__init__.py @@ -0,0 +1,27 @@ +"""A module for core functionality.""" + +from pyqit.core.callbacks import HistoryCallback +from pyqit.core.config import get_backend, set_backend +from pyqit.core.embeddings import AmplitudeEmbedding, AngleEmbedding, IQPEmbedding +from pyqit.core.losses import cross_entropy_loss, hinge_loss, mse_loss +from pyqit.core.measurements import measure_expval_x, measure_expval_z, measure_probs +from pyqit.core.pipeline import PipelineStage, QuantumPipeline +from pyqit.core.trainer import Trainer + +__all__ = [ + "Trainer", + "cross_entropy_loss", + "mse_loss", + "hinge_loss", + "AmplitudeEmbedding", + "AngleEmbedding", + "IQPEmbedding", + "measure_probs", + "measure_expval_z", + "measure_expval_x", + "HistoryCallback", + "set_backend", + "get_backend", + "QuantumPipeline", + "PipelineStage", +] diff --git a/pyqit/core/adapters/__init__.py b/pyqit/core/adapters/__init__.py index e69de29..1ac11f5 100644 --- a/pyqit/core/adapters/__init__.py +++ b/pyqit/core/adapters/__init__.py @@ -0,0 +1,5 @@ +"""A module for adapters.""" + +from pyqit.core.adapters.lightning import _LightningDataAdapter, _LightningModelAdapter + +__all__ = ["_LightningModelAdapter", "_LightningDataAdapter"] diff --git a/pyqit/core/adapters/lightning.py b/pyqit/core/adapters/lightning.py index d05914b..c3a05ff 100644 --- a/pyqit/core/adapters/lightning.py +++ b/pyqit/core/adapters/lightning.py @@ -21,6 +21,28 @@ def __init__(self, *args, **kwargs): class _LightningModelAdapter(LightningModule): + """ + Adapter to wrap a model into a PyTorch Lightning Module. + + This class handles the training loop, validation loop, and optimizer + configuration for the underlying PyQIT model, integrating seamlessly + with the PyTorch Lightning Trainer. + + Parameters + ---------- + pyqit_model : torch.nn.Module or callable + The core model to be wrapped. If it contains a `_qnodes` dictionary + with `torch.nn.Module` objects, they will be registered as submodules. + lr : float + The learning rate for the optimizer. + optimizer_name : str + The name of the optimizer to use. Supports "sgd"; defaults to "Adam" + for all other values. + loss_fn : callable + The loss function used for training and validation. It must accept + `preds` and `y` as arguments. + """ + def __init__(self, pyqit_model, lr, optimizer_name, loss_fn): super().__init__() self.pyqit_model = pyqit_model @@ -70,6 +92,27 @@ def configure_optimizers(self): class _LightningDataAdapter(LightningDataModule): + """ + Adapter to wrap a PyQIT data module into a PyTorch Lightning DataModule. + + This class extracts the internal training, validation, and testing arrays + from the PyQIT data module and converts them into standard PyTorch + DataLoaders compatible with the PyTorch Lightning Trainer. + + Parameters + ---------- + pyqit_dm : object + The internal PyQIT data module containing the raw data attributes + (`_X_train`, `_y_train`, etc.) and configuration parameters. + num_workers : int, default=0 + The number of workers for data loading. (Note: Currently overridden + by `pyqit_dm.num_workers` in the loader configuration). + train_loader_kwargs : dict or None, optional + Additional keyword arguments to pass to the training DataLoader. + eval_loader_kwargs : dict or None, optional + Additional keyword arguments to pass to the evaluation DataLoader. + """ + def __init__( self, pyqit_dm, diff --git a/pyqit/core/callbacks/__init__.py b/pyqit/core/callbacks/__init__.py index e69de29..2bad1c4 100644 --- a/pyqit/core/callbacks/__init__.py +++ b/pyqit/core/callbacks/__init__.py @@ -0,0 +1,5 @@ +"""A module for callbacks.""" + +from pyqit.core.callbacks.history import HistoryCallback + +__all__ = ["HistoryCallback"] diff --git a/pyqit/core/config.py b/pyqit/core/config.py index 07e1fc9..cc52368 100644 --- a/pyqit/core/config.py +++ b/pyqit/core/config.py @@ -8,6 +8,7 @@ def set_backend(backend: str): + """Set the quantum computing backend for the current context.""" backend = backend.lower() if backend not in ["pennylane", "torch"]: raise ValueError(f"Unsupported backend '{backend}'.") @@ -19,6 +20,7 @@ def set_backend(backend: str): def get_backend() -> str: + """Get the current quantum computing backend for the context.""" if not _EXPLICITLY_SET.get(): logger.warning( "No backend explicitly set for this context. Defaulting to 'pennylane'." diff --git a/pyqit/core/embeddings.py b/pyqit/core/embeddings.py index b8144d9..584349b 100644 --- a/pyqit/core/embeddings.py +++ b/pyqit/core/embeddings.py @@ -45,6 +45,8 @@ def prescale_key(cls) -> str | None: class AngleEmbedding(BaseEmbedding): + """A wrapper for PennyLane's AngleEmbedding circuit.""" + _tags = { "embedding_type": "angle", "differentiable": True, @@ -67,6 +69,8 @@ def get_test_params(cls): class AmplitudeEmbedding(BaseEmbedding): + """A wrapper for PennyLane's AmplitudeEmbedding circuit.""" + _tags = { "embedding_type": "amplitude", "differentiable": False, @@ -93,6 +97,8 @@ def get_test_params(cls): class IQPEmbedding(BaseEmbedding): + """A wrapper for PennyLane's IQPEmbedding circuit.""" + _tags = { "embedding_type": "iqp", "differentiable": False, diff --git a/pyqit/core/losses.py b/pyqit/core/losses.py index 04a3305..6111888 100644 --- a/pyqit/core/losses.py +++ b/pyqit/core/losses.py @@ -2,15 +2,58 @@ def mse_loss(preds, targets): + """Mean Squared Error loss function. + Parameters + ---------- + preds : array-like + The predicted values from the model. + targets : array-like + The ground truth target values. Expected to have the same shape + as `preds`. + + Returns + ------- + float or tensor + The computed mean squared error loss across the batch. + """ return pnp.mean((preds - targets) ** 2) def hinge_loss(preds, targets): + """Hinge loss function for binary classification. + Parameters + ---------- + preds : array-like + The predicted raw scores (logits) from the model. + targets : array-like + The ground truth binary labels, expected to be encoded as 0 or 1. + + Returns + ------- + float or tensor + The computed mean hinge loss across the batch.""" y_signed = 2.0 * targets - 1.0 return pnp.mean(pnp.maximum(0, 1 - y_signed * preds)) def cross_entropy_loss(preds, targets): + """Cross-entropy loss function for binary and multi-class classification. + + Parameters + ---------- + preds : array-like + The predicted probabilities from the model. For binary classification, + this should be a 1D array or a 2D array of shape `(n_samples, 1)`. + For multi-class classification, this should be a 2D array of shape + `(n_samples, n_classes)`. + targets : array-like + The ground truth labels. For binary classification, values should be + 0 or 1. For multi-class classification, values should be integer + class indices in the range `[0, n_classes - 1]`. + Returns + ------- + float or tensor + The computed mean cross-entropy loss across the batch.""" probs = pnp.clip(preds, 1e-9, 1.0 - 1e-9) if probs.ndim == 1 or (probs.ndim == 2 and probs.shape[1] == 1): diff --git a/pyqit/data/__init__.py b/pyqit/data/__init__.py index e69de29..97d73b7 100644 --- a/pyqit/data/__init__.py +++ b/pyqit/data/__init__.py @@ -0,0 +1,7 @@ +"""A module for data handling.""" + +from pyqit.data.datamodule import DataModule + +__all__ = [ + "DataModule", +] diff --git a/pyqit/models/base/__init__.py b/pyqit/models/base/__init__.py index 31ef287..0ddaa95 100644 --- a/pyqit/models/base/__init__.py +++ b/pyqit/models/base/__init__.py @@ -1,3 +1,5 @@ +"""A module for base models.""" + from pyqit.models.base.base import BaseModel from pyqit.models.base.quantum_model import BaseQuantumModel