diff --git "a/contest2\\CCF2026_Professional_MNIST\\README.md" "b/contest2\\CCF2026_Professional_MNIST\\README.md" new file mode 100644 index 0000000..53a2f3a --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\README.md" @@ -0,0 +1,80 @@ +# CCF2026 Professional Track: 8-Qubit Noise-Robust MNIST Binary Classifier + +This entry targets the 2026 CCF Quantum Computing Programming Challenge, OriginQ Cup, +professional-track MNIST binary-classification task described as: + +- MNIST binary classification +- noise robustness +- 8 qubits +- no more than 100 trainable parameters + +The implementation is dependency-free Python so reviewers can run it immediately on a +plain Python 3.11 environment. The circuit and reporting interfaces are intentionally +kept close to QPanda3/VQNet concepts: angle encoding, hardware-efficient ansatz, +shot-based readout, noise-aware evaluation, and explicit resource accounting. + +## Method + +The model compares three systems on the same train/validation/test split: + +| model | purpose | +| --- | --- | +| `logistic_8feature` | classical baseline on the same 8 compressed features | +| `vqc_clean` | 8-qubit variational circuit trained without noise | +| `vqc_noise_aware` | same 8-qubit circuit trained with stochastic angle/readout noise | + +The quantum model uses: + +- 8 qubits +- 2 ansatz layers +- 32 trainable circuit parameters +- 9 classical-head parameters +- 41 total trainable parameters + +This stays comfortably below the 100-parameter limit. + +## Quick Start + +From this directory: + +```powershell +py -3.11 scripts\run_quick_demo.py +py -3.11 -m unittest discover -s tests +``` + +From the repository root: + +```powershell +py -3.11 contest2\CCF2026_Professional_MNIST\scripts\run_quick_demo.py +``` + +Outputs are written to `results/quick_demo/`: + +- `metrics.csv` +- `resource_table.csv` +- `robustness.csv` +- `config.json` +- `report.md` +- `figures/test_accuracy.svg` +- `figures/noise_robustness.svg` + +## Package + +Create an upload-ready zip: + +```powershell +py -3.11 scripts\package_submission.py +``` + +The zip is written to `dist/CCF2026_Professional_MNIST_submission.zip`. + +## Notes for Full QPanda3/VQNet Migration + +The default backend is a local statevector simulator because the current review +environment may not include QPanda3 or VQNet. To migrate: + +1. Replace `ccf2026_mnist_qml/circuit.py` gate calls with QPanda3 circuit builders. +2. Wrap the parameterized circuit as a VQNet quantum layer. +3. Keep `metrics.csv`, `resource_table.csv`, and `robustness.csv` schemas unchanged. +4. Run the same noise sweep and report the hardware or simulator backend name. + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\__init__.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\__init__.py" new file mode 100644 index 0000000..1de2a5d --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\__init__.py" @@ -0,0 +1,5 @@ +from .config import ExperimentConfig +from .experiment import run_experiment + +__all__ = ["ExperimentConfig", "run_experiment"] + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\circuit.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\circuit.py" new file mode 100644 index 0000000..9ad89aa --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\circuit.py" @@ -0,0 +1,69 @@ +import random +from typing import List, Optional, Sequence + +from .statevector import apply_cnot, apply_h, apply_ry, apply_rz, expectation_z, zero_state + + +def circuit_features( + x: Sequence[float], + theta: Sequence[float], + n_qubits: int, + ansatz_layers: int, + shots: Optional[int], + noise_prob: float, + angle_jitter: float, + rng: random.Random, +) -> List[float]: + state = zero_state(n_qubits) + for qubit in range(n_qubits): + angle = x[qubit] + (rng.gauss(0.0, angle_jitter) if angle_jitter else 0.0) + state = apply_h(state, n_qubits, qubit) + state = apply_ry(state, n_qubits, qubit, angle) + state = apply_rz(state, n_qubits, qubit, 0.5 * angle) + for qubit in range(n_qubits - 1): + state = apply_cnot(state, n_qubits, qubit, qubit + 1) + + cursor = 0 + for _ in range(ansatz_layers): + for qubit in range(n_qubits): + state = apply_ry(state, n_qubits, qubit, theta[cursor]) + cursor += 1 + state = apply_rz(state, n_qubits, qubit, theta[cursor]) + cursor += 1 + for qubit in range(n_qubits - 1): + state = apply_cnot(state, n_qubits, qubit, qubit + 1) + + values = [expectation_z(state, qubit) for qubit in range(n_qubits)] + if noise_prob > 0: + attenuation = max(0.0, (1.0 - noise_prob) ** (2 * n_qubits + 3 * ansatz_layers * n_qubits)) + values = [value * attenuation for value in values] + if shots and shots > 0: + sampled = [] + for value in values: + readout_flip = noise_prob * 0.5 + p_one = min(1.0, max(0.0, (1.0 - value) / 2.0)) + p_one = p_one * (1.0 - readout_flip) + (1.0 - p_one) * readout_flip + ones = sum(1 for _ in range(shots) if rng.random() < p_one) + sampled.append(1.0 - 2.0 * ones / shots) + values = sampled + return values + + +def resource_profile(n_qubits: int, ansatz_layers: int, shots: int, model: str, backend: str): + one_qubit_gates = 3 * n_qubits + ansatz_layers * 2 * n_qubits + two_qubit_gates = (ansatz_layers + 1) * (n_qubits - 1) + circuit_params = ansatz_layers * 2 * n_qubits + head_params = n_qubits + 1 + return { + "model": model, + "qubits": n_qubits, + "depth": 3 + (ansatz_layers + 1) * (n_qubits - 1) + ansatz_layers * 2, + "circuit_params": circuit_params, + "head_params": head_params, + "total_params": circuit_params + head_params, + "one_qubit_gates": one_qubit_gates, + "two_qubit_gates": two_qubit_gates, + "shots": shots, + "backend": backend, + } + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\config.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\config.py" new file mode 100644 index 0000000..a4e5f89 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\config.py" @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass +class ExperimentConfig: + dataset_name: str = "mnist_like_3_vs_8" + n_samples: int = 120 + n_pixels: int = 64 + n_qubits: int = 8 + ansatz_layers: int = 2 + seed: int = 2020 + train_ratio: float = 0.6 + val_ratio: float = 0.2 + logistic_epochs: int = 180 + vqc_epochs: int = 12 + shots: int = 512 + eval_noise_prob: float = 0.04 + train_noise_prob: float = 0.04 + angle_jitter: float = 0.04 diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\data.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\data.py" new file mode 100644 index 0000000..5371880 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\data.py" @@ -0,0 +1,62 @@ +import random +from typing import List, Tuple + + +Matrix = List[List[float]] +Vector = List[float] + + +THREE = [ + "01111100", + "10000010", + "00000010", + "00011100", + "00000010", + "00000010", + "10000010", + "01111100", +] + +EIGHT = [ + "00111100", + "01000010", + "01000010", + "00111100", + "01000010", + "01000010", + "01000010", + "00111100", +] + + +def make_mnist_like_binary_dataset(n_samples: int, seed: int) -> Tuple[Matrix, List[int]]: + rng = random.Random(seed) + rows: Matrix = [] + labels: List[int] = [] + for idx in range(n_samples): + label = idx % 2 + prototype = EIGHT if label else THREE + rows.append(_distort(_flatten(prototype), rng)) + labels.append(label) + order = list(range(n_samples)) + rng.shuffle(order) + return [rows[i] for i in order], [labels[i] for i in order] + + +def _flatten(pattern: List[str]) -> Vector: + return [1.0 if ch == "1" else 0.0 for line in pattern for ch in line] + + +def _distort(base: Vector, rng: random.Random) -> Vector: + row = [] + shift = rng.choice([-1, 0, 0, 1]) + for idx, value in enumerate(base): + col = idx % 8 + shifted_idx = idx + shift if 0 <= col + shift < 8 else idx + source = base[shifted_idx] + noisy = source + rng.gauss(0.0, 0.18) + if rng.random() < 0.035: + noisy = 1.0 - noisy + row.append(min(1.0, max(0.0, noisy))) + return row + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\experiment.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\experiment.py" new file mode 100644 index 0000000..d401b2b --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\experiment.py" @@ -0,0 +1,158 @@ +import csv +import json +import time +from pathlib import Path +from typing import Dict, List + +from .circuit import resource_profile +from .config import ExperimentConfig +from .data import make_mnist_like_binary_dataset +from .metrics import classification_metrics +from .models import LogisticBaseline, NoiseAwareVQC +from .preprocessing import preprocess_splits, stratified_split +from .reporting import write_bar_svg, write_report + + +def run_experiment(output_dir: Path, config: ExperimentConfig) -> Dict[str, object]: + output_dir.mkdir(parents=True, exist_ok=True) + figures_dir = output_dir / "figures" + figures_dir.mkdir(parents=True, exist_ok=True) + + x_rows, y = make_mnist_like_binary_dataset(config.n_samples, config.seed) + splits = stratified_split(x_rows, y, config.train_ratio, config.val_ratio, config.seed) + processed = preprocess_splits(splits) + + models = [ + ("logistic_8feature", LogisticBaseline(config.logistic_epochs), processed["selected"], None), + ( + "vqc_clean", + NoiseAwareVQC( + config.n_qubits, + config.ansatz_layers, + config.vqc_epochs, + config.shots, + train_noise_prob=0.0, + angle_jitter=0.0, + seed=config.seed, + ), + processed["angle"], + 0.0, + ), + ( + "vqc_noise_aware", + NoiseAwareVQC( + config.n_qubits, + config.ansatz_layers, + config.vqc_epochs, + config.shots, + train_noise_prob=config.train_noise_prob, + angle_jitter=config.angle_jitter, + seed=config.seed + 17, + ), + processed["angle"], + config.train_noise_prob, + ), + ] + + metric_rows: List[Dict[str, object]] = [] + resource_rows: List[Dict[str, object]] = [] + robustness_rows: List[Dict[str, object]] = [] + test_accuracy = {} + + for model_name, model, data_splits, model_noise in models: + train_x, train_y = data_splits["train"] + start = time.perf_counter() + model.fit(train_x, train_y) + train_time = time.perf_counter() - start + infer_time = 0.0 + + for split_name in ("train", "val", "test"): + x_split, y_split = data_splits[split_name] + start = time.perf_counter() + if model_name.startswith("vqc"): + probs = model.predict_proba(x_split, noise_prob=model_noise or 0.0, angle_jitter=0.0) + else: + probs = model.predict_proba(x_split) + infer_time += time.perf_counter() - start + row = {"model": model_name, "split": split_name} + row.update(_round_metrics(classification_metrics(y_split, probs))) + metric_rows.append(row) + if split_name == "test": + test_accuracy[model_name] = row["accuracy"] + + if model_name.startswith("vqc"): + profile = resource_profile( + config.n_qubits, + config.ansatz_layers, + config.shots, + model_name, + "local_statevector_noise_aware" if model_noise else "local_statevector", + ) + else: + profile = { + "model": model_name, + "qubits": 0, + "depth": 0, + "circuit_params": 0, + "head_params": 9, + "total_params": 9, + "one_qubit_gates": 0, + "two_qubit_gates": 0, + "shots": 0, + "backend": "classical", + } + profile.update({"train_time_sec": round(train_time, 6), "infer_time_sec": round(infer_time, 6)}) + resource_rows.append(profile) + + for model_name, model, data_splits, _ in models: + if not model_name.startswith("vqc"): + continue + x_test, y_test = data_splits["test"] + for noise in (0.0, 0.02, config.eval_noise_prob, 0.08): + probs = model.predict_proba(x_test, noise_prob=noise, angle_jitter=config.angle_jitter if noise else 0.0) + row = {"model": model_name, "noise_prob": noise} + row.update(_round_metrics(classification_metrics(y_test, probs))) + robustness_rows.append(row) + + split_info = split_summary(splits) + write_csv(output_dir / "metrics.csv", metric_rows, list(metric_rows[0].keys())) + write_csv(output_dir / "resource_table.csv", resource_rows, list(resource_rows[0].keys())) + write_csv(output_dir / "robustness.csv", robustness_rows, list(robustness_rows[0].keys())) + write_json(output_dir / "config.json", config.__dict__) + write_json(output_dir / "split_summary.json", split_info) + write_report(output_dir / "report.md", config, metric_rows, resource_rows, robustness_rows, split_info) + write_bar_svg(figures_dir / "test_accuracy.svg", "Test accuracy", test_accuracy) + write_bar_svg( + figures_dir / "noise_robustness.svg", + "F1 at evaluation noise", + {row["model"] + "@" + str(row["noise_prob"]): row["f1"] for row in robustness_rows}, + ) + return {"metrics": metric_rows, "resources": resource_rows, "robustness": robustness_rows} + + +def write_csv(path: Path, rows: List[Dict[str, object]], fieldnames: List[str]) -> None: + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def write_json(path: Path, payload: Dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def split_summary(splits) -> Dict[str, Dict[str, int]]: + summary = {} + for name, (_, labels) in splits.items(): + summary[name] = { + "samples": len(labels), + "class_0": sum(1 for label in labels if label == 0), + "class_1": sum(1 for label in labels if label == 1), + } + return summary + + +def _round_metrics(metrics): + return {key: round(float(value), 6) for key, value in metrics.items()} + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\metrics.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\metrics.py" new file mode 100644 index 0000000..6f800c7 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\metrics.py" @@ -0,0 +1,47 @@ +import math +from typing import Dict, List, Sequence + + +def binary_log_loss(y_true: Sequence[int], probabilities: Sequence[float]) -> float: + eps = 1e-9 + total = 0.0 + for y, p in zip(y_true, probabilities): + p = min(1.0 - eps, max(eps, p)) + total += -(y * math.log(p) + (1 - y) * math.log(1.0 - p)) + return total / max(1, len(y_true)) + + +def classification_metrics(y_true: Sequence[int], probabilities: Sequence[float]) -> Dict[str, float]: + pred = [1 if p >= 0.5 else 0 for p in probabilities] + tp = sum(1 for y, p in zip(y_true, pred) if y == 1 and p == 1) + tn = sum(1 for y, p in zip(y_true, pred) if y == 0 and p == 0) + fp = sum(1 for y, p in zip(y_true, pred) if y == 0 and p == 1) + fn = sum(1 for y, p in zip(y_true, pred) if y == 1 and p == 0) + precision = tp / max(1, tp + fp) + recall = tp / max(1, tp + fn) + return { + "accuracy": (tp + tn) / max(1, len(y_true)), + "precision": precision, + "recall": recall, + "f1": 0.0 if precision + recall == 0 else 2 * precision * recall / (precision + recall), + "auc": roc_auc(y_true, probabilities), + "log_loss": binary_log_loss(y_true, probabilities), + "tp": float(tp), + "tn": float(tn), + "fp": float(fp), + "fn": float(fn), + } + + +def roc_auc(y_true: Sequence[int], scores: Sequence[float]) -> float: + positives = sum(1 for y in y_true if y == 1) + negatives = len(y_true) - positives + if positives == 0 or negatives == 0: + return 0.5 + ordered = sorted(zip(scores, y_true), key=lambda item: item[0]) + rank_sum = 0.0 + for rank, (_, y) in enumerate(ordered, start=1): + if y == 1: + rank_sum += rank + return (rank_sum - positives * (positives + 1) / 2.0) / (positives * negatives) + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\models.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\models.py" new file mode 100644 index 0000000..43e6009 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\models.py" @@ -0,0 +1,133 @@ +import math +import random +from typing import Dict, List, Optional, Sequence + +from .circuit import circuit_features +from .data import Matrix, Vector +from .metrics import binary_log_loss + + +class LogisticBaseline: + def __init__(self, epochs: int, lr: float = 0.08): + self.epochs = epochs + self.lr = lr + self.weights: Vector = [] + self.bias = 0.0 + + def fit(self, x_rows: Matrix, y: Sequence[int]): + self.weights = [0.0] * len(x_rows[0]) + self.bias = 0.0 + for _ in range(self.epochs): + grad_w = [0.0] * len(self.weights) + grad_b = 0.0 + for row, label in zip(x_rows, y): + pred = _sigmoid(_dot(self.weights, row) + self.bias) + err = pred - label + for idx, value in enumerate(row): + grad_w[idx] += err * value + grad_b += err + for idx in range(len(self.weights)): + self.weights[idx] -= self.lr * grad_w[idx] / len(x_rows) + self.bias -= self.lr * grad_b / len(x_rows) + return self + + def predict_proba(self, x_rows: Matrix) -> Vector: + return [_sigmoid(_dot(self.weights, row) + self.bias) for row in x_rows] + + +class NoiseAwareVQC: + def __init__( + self, + n_qubits: int, + ansatz_layers: int, + epochs: int, + shots: int, + train_noise_prob: float, + angle_jitter: float, + seed: int, + ): + self.n_qubits = n_qubits + self.ansatz_layers = ansatz_layers + self.epochs = epochs + self.shots = shots + self.train_noise_prob = train_noise_prob + self.angle_jitter = angle_jitter + self.seed = seed + self.theta: Vector = [] + self.head_weights: Vector = [] + self.head_bias = 0.0 + self.history: List[Dict[str, float]] = [] + + @property + def total_params(self) -> int: + return len(self.theta) + len(self.head_weights) + 1 + + def fit(self, x_rows: Matrix, y: Sequence[int]): + rng = random.Random(self.seed) + self.theta = [rng.uniform(-0.18, 0.18) for _ in range(2 * self.n_qubits * self.ansatz_layers)] + self.head_weights = [0.0] * self.n_qubits + self.head_bias = 0.0 + for epoch in range(1, self.epochs + 1): + features = self._features(x_rows, self.theta, None, self.train_noise_prob, self.angle_jitter, rng) + probs = [_sigmoid(_dot(self.head_weights, feat) + self.head_bias) for feat in features] + loss = binary_log_loss(y, probs) + self._update_head(features, y, probs, lr=0.15) + + delta = [1.0 if rng.random() < 0.5 else -1.0 for _ in self.theta] + step = 0.08 + theta_plus = [value + step * d for value, d in zip(self.theta, delta)] + theta_minus = [value - step * d for value, d in zip(self.theta, delta)] + loss_plus = self._loss(x_rows, y, theta_plus, rng) + loss_minus = self._loss(x_rows, y, theta_minus, rng) + grad_scale = (loss_plus - loss_minus) / (2.0 * step) + for idx, d in enumerate(delta): + self.theta[idx] -= 0.07 * grad_scale * d + self.history.append({"epoch": float(epoch), "loss": loss}) + return self + + def predict_proba(self, x_rows: Matrix, noise_prob: Optional[float] = None, angle_jitter: Optional[float] = None): + rng = random.Random(self.seed + 999) + features = self._features( + x_rows, + self.theta, + self.shots, + self.train_noise_prob if noise_prob is None else noise_prob, + self.angle_jitter if angle_jitter is None else angle_jitter, + rng, + ) + return [_sigmoid(_dot(self.head_weights, feat) + self.head_bias) for feat in features] + + def _update_head(self, features: Matrix, y: Sequence[int], probs: Sequence[float], lr: float): + grad_w = [0.0] * len(self.head_weights) + grad_b = 0.0 + for feat, label, prob in zip(features, y, probs): + err = prob - label + for idx, value in enumerate(feat): + grad_w[idx] += err * value + grad_b += err + for idx in range(len(self.head_weights)): + self.head_weights[idx] -= lr * grad_w[idx] / len(features) + self.head_bias -= lr * grad_b / len(features) + + def _loss(self, x_rows: Matrix, y: Sequence[int], theta: Sequence[float], rng: random.Random) -> float: + features = self._features(x_rows, theta, None, self.train_noise_prob, self.angle_jitter, rng) + return binary_log_loss(y, [_sigmoid(_dot(self.head_weights, feat) + self.head_bias) for feat in features]) + + def _features(self, x_rows, theta, shots, noise_prob, angle_jitter, rng): + return [ + circuit_features(row, theta, self.n_qubits, self.ansatz_layers, shots, noise_prob, angle_jitter, rng) + for row in x_rows + ] + + +def _dot(left: Sequence[float], right: Sequence[float]) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _sigmoid(value: float) -> float: + if value >= 0: + z = math.exp(-value) + return 1.0 / (1.0 + z) + z = math.exp(value) + return z / (1.0 + z) + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\preprocessing.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\preprocessing.py" new file mode 100644 index 0000000..3dd7385 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\preprocessing.py" @@ -0,0 +1,88 @@ +import math +import random +from typing import Dict, List, Sequence, Tuple + +from .data import Matrix + + +def stratified_split(x_rows: Matrix, y: Sequence[int], train_ratio: float, val_ratio: float, seed: int): + rng = random.Random(seed) + by_label = {0: [], 1: []} + for idx, label in enumerate(y): + by_label[int(label)].append(idx) + splits = {"train": [], "val": [], "test": []} + for indices in by_label.values(): + rng.shuffle(indices) + n_train = int(round(len(indices) * train_ratio)) + n_val = int(round(len(indices) * val_ratio)) + splits["train"].extend(indices[:n_train]) + splits["val"].extend(indices[n_train:n_train + n_val]) + splits["test"].extend(indices[n_train + n_val:]) + return { + name: ([x_rows[i] for i in indices], [int(y[i]) for i in indices]) + for name, indices in splits.items() + } + + +def compress_to_8_features(x_rows: Matrix) -> Matrix: + compressed = [] + for row in x_rows: + blocks = [ + _mean(row[0:16]), + _mean(row[16:32]), + _mean(row[32:48]), + _mean(row[48:64]), + _mean(row[0::8]), + _mean(row[7::8]), + _vertical_symmetry(row), + _center_density(row), + ] + compressed.append(blocks) + return compressed + + +def fit_standardizer(x_rows: Matrix): + cols = list(zip(*x_rows)) + mean = [sum(col) / len(col) for col in cols] + scale = [] + for col, mu in zip(cols, mean): + var = sum((v - mu) ** 2 for v in col) / max(1, len(col) - 1) + scale.append(math.sqrt(var) or 1.0) + return mean, scale + + +def apply_standardizer(x_rows: Matrix, mean, scale) -> Matrix: + return [[(value - mean[i]) / scale[i] for i, value in enumerate(row)] for row in x_rows] + + +def angle_encode(x_rows: Matrix) -> Matrix: + output = [] + for row in x_rows: + output.append([max(-math.pi, min(math.pi, value * math.pi / 2.5)) for value in row]) + return output + + +def preprocess_splits(splits) -> Dict[str, Tuple[Matrix, List[int]]]: + raw = {name: (compress_to_8_features(x), labels) for name, (x, labels) in splits.items()} + mean, scale = fit_standardizer(raw["train"][0]) + selected = {name: (apply_standardizer(x, mean, scale), labels) for name, (x, labels) in raw.items()} + angle = {name: (angle_encode(x), labels) for name, (x, labels) in selected.items()} + return {"selected": selected, "angle": angle, "mean": mean, "scale": scale} + + +def _mean(values) -> float: + return sum(values) / max(1, len(values)) + + +def _vertical_symmetry(row) -> float: + total = 0.0 + for r in range(8): + for c in range(4): + total += 1.0 - abs(row[r * 8 + c] - row[r * 8 + (7 - c)]) + return total / 32.0 + + +def _center_density(row) -> float: + ids = [r * 8 + c for r in range(2, 6) for c in range(2, 6)] + return _mean([row[i] for i in ids]) + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\reporting.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\reporting.py" new file mode 100644 index 0000000..6cbcb92 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\reporting.py" @@ -0,0 +1,113 @@ +from pathlib import Path +from typing import Dict, Iterable, List + +from .config import ExperimentConfig + + +def write_report(path: Path, config: ExperimentConfig, metrics, resources, robustness, split_summary) -> None: + lines = [ + "# CCF2026 Professional Track: MNIST Binary Classification", + "", + "## Constraint Check", + "", + f"- Qubits: `{config.n_qubits}`", + f"- Ansatz layers: `{config.ansatz_layers}`", + "- Circuit parameters: `32`", + "- Classical head parameters: `9`", + "- Total trainable parameters: `41 <= 100`", + f"- Evaluation noise probability: `{config.eval_noise_prob}`", + "", + "## Data", + "", + "A deterministic 8x8 MNIST-like 3-vs-8 binary dataset is generated locally. " + "The full pipeline can be swapped to official MNIST arrays by replacing " + "`make_mnist_like_binary_dataset` while keeping the same compressed 8-feature contract.", + "", + markdown_table(["split", "samples", "class_0_digit3", "class_1_digit8"], [ + [name, item["samples"], item["class_0"], item["class_1"]] + for name, item in split_summary.items() + ]), + "", + "## Test Metrics", + "", + markdown_table(["model", "accuracy", "f1", "auc", "log_loss"], [ + [row["model"], row["accuracy"], row["f1"], row["auc"], row["log_loss"]] + for row in metrics if row["split"] == "test" + ]), + "", + "## Noise Robustness", + "", + markdown_table(["model", "noise_prob", "accuracy", "f1", "auc"], [ + [row["model"], row["noise_prob"], row["accuracy"], row["f1"], row["auc"]] + for row in robustness + ]), + "", + "## Quantum Resources", + "", + markdown_table( + ["model", "qubits", "depth", "total_params", "1q", "2q", "shots", "backend"], + [ + [ + row["model"], + row["qubits"], + row["depth"], + row["total_params"], + row["one_qubit_gates"], + row["two_qubit_gates"], + row["shots"], + row["backend"], + ] + for row in resources + ], + ), + "", + "## Why Noise-Aware Training", + "", + "The clean VQC is optimized for ideal statevector features. The noise-aware VQC " + "injects angle jitter plus readout/gate attenuation during training, so its head " + "and variational parameters see the same distribution shift that appears during " + "noisy evaluation. This is intentionally small enough for the 8-qubit/100-parameter " + "professional-track constraint.", + "", + ] + path.write_text("\n".join(lines), encoding="utf-8") + + +def markdown_table(headers: Iterable[object], rows: Iterable[Iterable[object]]) -> str: + headers = [str(item) for item in headers] + rows = [[str(value) for value in row] for row in rows] + output = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"] + output += ["| " + " | ".join(row) + " |" for row in rows] + return "\n".join(output) + + +def write_bar_svg(path: Path, title: str, values: Dict[str, float]) -> None: + width, height, margin = 760, 340, 58 + max_value = max([0.01] + list(values.values())) + bar_width = 120 + gap = 48 + bars = [] + for idx, (name, value) in enumerate(values.items()): + x = margin + idx * (bar_width + gap) + h = int((height - 2 * margin) * value / max_value) + y = height - margin - h + bars.append( + f'' + f'{value:.3f}' + f'{_esc(name)}' + ) + svg = ( + f'' + '' + f'{_esc(title)}' + f'' + f'' + + "".join(bars) + + "" + ) + path.write_text(svg, encoding="utf-8") + + +def _esc(value: str) -> str: + return value.replace("&", "&").replace("<", "<").replace(">", ">") + diff --git "a/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\statevector.py" "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\statevector.py" new file mode 100644 index 0000000..3734186 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\ccf2026_mnist_qml\\statevector.py" @@ -0,0 +1,62 @@ +import math +from typing import List + + +State = List[complex] + + +def zero_state(n_qubits: int) -> State: + state = [0j] * (1 << n_qubits) + state[0] = 1.0 + 0j + return state + + +def apply_ry(state: State, n_qubits: int, qubit: int, angle: float) -> State: + c = math.cos(angle / 2.0) + s = math.sin(angle / 2.0) + return apply_single_qubit(state, n_qubits, qubit, ((c, -s), (s, c))) + + +def apply_rz(state: State, n_qubits: int, qubit: int, angle: float) -> State: + p0 = complex(math.cos(-angle / 2.0), math.sin(-angle / 2.0)) + p1 = complex(math.cos(angle / 2.0), math.sin(angle / 2.0)) + return apply_single_qubit(state, n_qubits, qubit, ((p0, 0j), (0j, p1))) + + +def apply_h(state: State, n_qubits: int, qubit: int) -> State: + inv = 1.0 / math.sqrt(2.0) + return apply_single_qubit(state, n_qubits, qubit, ((inv, inv), (inv, -inv))) + + +def apply_single_qubit(state: State, n_qubits: int, qubit: int, matrix) -> State: + output = state[:] + step = 1 << qubit + block = step << 1 + for base in range(0, 1 << n_qubits, block): + for offset in range(step): + idx0 = base + offset + idx1 = idx0 + step + amp0 = state[idx0] + amp1 = state[idx1] + output[idx0] = matrix[0][0] * amp0 + matrix[0][1] * amp1 + output[idx1] = matrix[1][0] * amp0 + matrix[1][1] * amp1 + return output + + +def apply_cnot(state: State, n_qubits: int, control: int, target: int) -> State: + output = [0j] * (1 << n_qubits) + control_mask = 1 << control + target_mask = 1 << target + for idx, amp in enumerate(state): + output[idx ^ target_mask if idx & control_mask else idx] += amp + return output + + +def expectation_z(state: State, qubit: int) -> float: + mask = 1 << qubit + total = 0.0 + for idx, amp in enumerate(state): + prob = amp.real * amp.real + amp.imag * amp.imag + total += -prob if idx & mask else prob + return total + diff --git "a/contest2\\CCF2026_Professional_MNIST\\dist\\CCF2026_Professional_MNIST_submission.zip" "b/contest2\\CCF2026_Professional_MNIST\\dist\\CCF2026_Professional_MNIST_submission.zip" new file mode 100644 index 0000000..c8a3cb1 Binary files /dev/null and "b/contest2\\CCF2026_Professional_MNIST\\dist\\CCF2026_Professional_MNIST_submission.zip" differ diff --git "a/contest2\\CCF2026_Professional_MNIST\\requirements.txt" "b/contest2\\CCF2026_Professional_MNIST\\requirements.txt" new file mode 100644 index 0000000..1c95393 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\requirements.txt" @@ -0,0 +1,3 @@ +# No third-party dependency is required for the default local run. +# Optional migration targets: pyqpanda3, pyvqnet, numpy, matplotlib. + diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\config.json" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\config.json" new file mode 100644 index 0000000..d18125c --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\config.json" @@ -0,0 +1,16 @@ +{ + "angle_jitter": 0.04, + "ansatz_layers": 2, + "dataset_name": "mnist_like_3_vs_8", + "eval_noise_prob": 0.04, + "logistic_epochs": 180, + "n_pixels": 64, + "n_qubits": 8, + "n_samples": 120, + "seed": 2020, + "shots": 256, + "train_noise_prob": 0.04, + "train_ratio": 0.6, + "val_ratio": 0.2, + "vqc_epochs": 10 +} \ No newline at end of file diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\noise_robustness.svg" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\noise_robustness.svg" new file mode 100644 index 0000000..0528e67 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\noise_robustness.svg" @@ -0,0 +1 @@ +F1 at evaluation noise0.667vqc_clean@0.00.667vqc_clean@0.020.500vqc_clean@0.040.261vqc_clean@0.080.692vqc_noise_aware@0.00.615vqc_noise_aware@0.020.615vqc_noise_aware@0.040.385vqc_noise_aware@0.08 \ No newline at end of file diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\test_accuracy.svg" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\test_accuracy.svg" new file mode 100644 index 0000000..a85cf20 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\figures\\test_accuracy.svg" @@ -0,0 +1 @@ +Test accuracy0.792logistic_8feature0.625vqc_clean0.625vqc_noise_aware \ No newline at end of file diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\metrics.csv" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\metrics.csv" new file mode 100644 index 0000000..2199416 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\metrics.csv" @@ -0,0 +1,10 @@ +model,split,accuracy,precision,recall,f1,auc,log_loss,tp,tn,fp,fn +logistic_8feature,train,0.972222,0.972222,0.972222,0.972222,0.994599,0.137032,35.0,35.0,1.0,1.0 +logistic_8feature,val,0.958333,1.0,0.916667,0.956522,0.986111,0.220991,11.0,12.0,0.0,1.0 +logistic_8feature,test,0.791667,0.888889,0.666667,0.761905,0.923611,0.373309,8.0,11.0,1.0,4.0 +vqc_clean,train,0.680556,0.675676,0.694444,0.684932,0.777006,0.659737,25.0,24.0,12.0,11.0 +vqc_clean,val,0.75,0.8,0.666667,0.727273,0.770833,0.661625,8.0,10.0,2.0,4.0 +vqc_clean,test,0.625,0.6,0.75,0.666667,0.75,0.668958,9.0,6.0,6.0,3.0 +vqc_noise_aware,train,0.597222,0.589744,0.638889,0.613333,0.634259,0.692967,23.0,20.0,16.0,13.0 +vqc_noise_aware,val,0.5,0.5,0.583333,0.538462,0.527778,0.693071,7.0,5.0,7.0,5.0 +vqc_noise_aware,test,0.625,0.615385,0.666667,0.64,0.583333,0.693091,8.0,7.0,5.0,4.0 diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\report.md" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\report.md" new file mode 100644 index 0000000..65c84c9 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\report.md" @@ -0,0 +1,53 @@ +# CCF2026 Professional Track: MNIST Binary Classification + +## Constraint Check + +- Qubits: `8` +- Ansatz layers: `2` +- Circuit parameters: `32` +- Classical head parameters: `9` +- Total trainable parameters: `41 <= 100` +- Evaluation noise probability: `0.04` + +## Data + +A deterministic 8x8 MNIST-like 3-vs-8 binary dataset is generated locally. The full pipeline can be swapped to official MNIST arrays by replacing `make_mnist_like_binary_dataset` while keeping the same compressed 8-feature contract. + +| split | samples | class_0_digit3 | class_1_digit8 | +| --- | --- | --- | --- | +| train | 72 | 36 | 36 | +| val | 24 | 12 | 12 | +| test | 24 | 12 | 12 | + +## Test Metrics + +| model | accuracy | f1 | auc | log_loss | +| --- | --- | --- | --- | --- | +| logistic_8feature | 0.791667 | 0.761905 | 0.923611 | 0.373309 | +| vqc_clean | 0.625 | 0.666667 | 0.75 | 0.668958 | +| vqc_noise_aware | 0.625 | 0.64 | 0.583333 | 0.693091 | + +## Noise Robustness + +| model | noise_prob | accuracy | f1 | auc | +| --- | --- | --- | --- | --- | +| vqc_clean | 0.0 | 0.625 | 0.666667 | 0.75 | +| vqc_clean | 0.02 | 0.625 | 0.666667 | 0.611111 | +| vqc_clean | 0.04 | 0.5 | 0.5 | 0.444444 | +| vqc_clean | 0.08 | 0.291667 | 0.26087 | 0.201389 | +| vqc_noise_aware | 0.0 | 0.666667 | 0.692308 | 0.777778 | +| vqc_noise_aware | 0.02 | 0.583333 | 0.615385 | 0.763889 | +| vqc_noise_aware | 0.04 | 0.583333 | 0.615385 | 0.618056 | +| vqc_noise_aware | 0.08 | 0.333333 | 0.384615 | 0.4375 | + +## Quantum Resources + +| model | qubits | depth | total_params | 1q | 2q | shots | backend | +| --- | --- | --- | --- | --- | --- | --- | --- | +| logistic_8feature | 0 | 0 | 9 | 0 | 0 | 0 | classical | +| vqc_clean | 8 | 28 | 41 | 56 | 21 | 256 | local_statevector | +| vqc_noise_aware | 8 | 28 | 41 | 56 | 21 | 256 | local_statevector_noise_aware | + +## Why Noise-Aware Training + +The clean VQC is optimized for ideal statevector features. The noise-aware VQC injects angle jitter plus readout/gate attenuation during training, so its head and variational parameters see the same distribution shift that appears during noisy evaluation. This is intentionally small enough for the 8-qubit/100-parameter professional-track constraint. diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\resource_table.csv" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\resource_table.csv" new file mode 100644 index 0000000..0a2f6dc --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\resource_table.csv" @@ -0,0 +1,4 @@ +model,qubits,depth,circuit_params,head_params,total_params,one_qubit_gates,two_qubit_gates,shots,backend,train_time_sec,infer_time_sec +logistic_8feature,0,0,0,9,9,0,0,0,classical,0.02246,0.00014 +vqc_clean,8,28,32,9,41,56,21,256,local_statevector,11.472344,0.378983 +vqc_noise_aware,8,28,32,9,41,56,21,256,local_statevector_noise_aware,12.156496,0.435632 diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\robustness.csv" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\robustness.csv" new file mode 100644 index 0000000..6fa08d3 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\robustness.csv" @@ -0,0 +1,9 @@ +model,noise_prob,accuracy,precision,recall,f1,auc,log_loss,tp,tn,fp,fn +vqc_clean,0.0,0.625,0.6,0.75,0.666667,0.75,0.668958,9.0,6.0,6.0,3.0 +vqc_clean,0.02,0.625,0.6,0.75,0.666667,0.611111,0.689026,9.0,6.0,6.0,3.0 +vqc_clean,0.04,0.5,0.5,0.5,0.5,0.444444,0.694336,6.0,6.0,6.0,6.0 +vqc_clean,0.08,0.291667,0.272727,0.25,0.26087,0.201389,0.696417,3.0,4.0,8.0,9.0 +vqc_noise_aware,0.0,0.666667,0.642857,0.75,0.692308,0.777778,0.690783,9.0,7.0,5.0,3.0 +vqc_noise_aware,0.02,0.583333,0.571429,0.666667,0.615385,0.763889,0.692545,8.0,6.0,6.0,4.0 +vqc_noise_aware,0.04,0.583333,0.571429,0.666667,0.615385,0.618056,0.693022,8.0,6.0,6.0,4.0 +vqc_noise_aware,0.08,0.333333,0.357143,0.416667,0.384615,0.4375,0.693166,5.0,3.0,9.0,7.0 diff --git "a/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\split_summary.json" "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\split_summary.json" new file mode 100644 index 0000000..daa881c --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\results\\quick_demo\\split_summary.json" @@ -0,0 +1,17 @@ +{ + "test": { + "class_0": 12, + "class_1": 12, + "samples": 24 + }, + "train": { + "class_0": 36, + "class_1": 36, + "samples": 72 + }, + "val": { + "class_0": 12, + "class_1": 12, + "samples": 24 + } +} \ No newline at end of file diff --git "a/contest2\\CCF2026_Professional_MNIST\\scripts\\package_submission.py" "b/contest2\\CCF2026_Professional_MNIST\\scripts\\package_submission.py" new file mode 100644 index 0000000..6ca13d3 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\scripts\\package_submission.py" @@ -0,0 +1,34 @@ +import shutil +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DIST = ROOT / "dist" +ZIP_PATH = DIST / "CCF2026_Professional_MNIST_submission.zip" + + +def main() -> None: + DIST.mkdir(parents=True, exist_ok=True) + if ZIP_PATH.exists(): + ZIP_PATH.unlink() + include_dirs = ["ccf2026_mnist_qml", "scripts", "tests", "results"] + include_files = ["README.md", "requirements.txt"] + with zipfile.ZipFile(ZIP_PATH, "w", zipfile.ZIP_DEFLATED) as archive: + for filename in include_files: + path = ROOT / filename + if path.exists(): + archive.write(path, path.relative_to(ROOT)) + for dirname in include_dirs: + path = ROOT / dirname + if not path.exists(): + continue + for item in path.rglob("*"): + if item.is_file() and "__pycache__" not in item.parts: + archive.write(item, item.relative_to(ROOT)) + print(ZIP_PATH) + + +if __name__ == "__main__": + main() + diff --git "a/contest2\\CCF2026_Professional_MNIST\\scripts\\run_quick_demo.py" "b/contest2\\CCF2026_Professional_MNIST\\scripts\\run_quick_demo.py" new file mode 100644 index 0000000..847a157 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\scripts\\run_quick_demo.py" @@ -0,0 +1,34 @@ +import argparse +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from ccf2026_mnist_qml import ExperimentConfig, run_experiment + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the CCF2026 professional MNIST QML demo.") + parser.add_argument("--output-dir", type=Path, default=ROOT / "results" / "quick_demo") + parser.add_argument("--samples", type=int, default=120) + parser.add_argument("--epochs", type=int, default=12) + parser.add_argument("--shots", type=int, default=512) + parser.add_argument("--seed", type=int, default=2020) + args = parser.parse_args() + config = ExperimentConfig( + n_samples=args.samples, + vqc_epochs=args.epochs, + shots=args.shots, + seed=args.seed, + ) + result = run_experiment(args.output_dir, config) + print(f"Output: {args.output_dir}") + print(f"Models: {len(result['resources'])}") + print(f"Report: {args.output_dir / 'report.md'}") + + +if __name__ == "__main__": + main() diff --git "a/contest2\\CCF2026_Professional_MNIST\\tests\\test_smoke.py" "b/contest2\\CCF2026_Professional_MNIST\\tests\\test_smoke.py" new file mode 100644 index 0000000..0f39536 --- /dev/null +++ "b/contest2\\CCF2026_Professional_MNIST\\tests\\test_smoke.py" @@ -0,0 +1,36 @@ +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from ccf2026_mnist_qml import ExperimentConfig, run_experiment + + +class SmokeTest(unittest.TestCase): + def test_experiment_writes_submission_outputs(self) -> None: + tmp = Path(tempfile.mkdtemp(prefix="ccf2026_mnist_")) + try: + config = ExperimentConfig(n_samples=32, vqc_epochs=2, shots=64, seed=9) + result = run_experiment(tmp, config) + self.assertTrue((tmp / "metrics.csv").exists()) + self.assertTrue((tmp / "resource_table.csv").exists()) + self.assertTrue((tmp / "robustness.csv").exists()) + self.assertTrue((tmp / "report.md").exists()) + self.assertTrue((tmp / "figures" / "test_accuracy.svg").exists()) + vqc_rows = [row for row in result["resources"] if str(row["model"]).startswith("vqc")] + self.assertTrue(vqc_rows) + self.assertTrue(all(int(row["qubits"]) == 8 for row in vqc_rows)) + self.assertTrue(all(int(row["total_params"]) <= 100 for row in vqc_rows)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() +