|
| 1 | +Directional Classification Code (QNN vs ANN) |
| 2 | + |
| 3 | +The following code implements the directional prediction task as described in the paper “Quantum vs. Classical Machine Learning: A Benchmark Study for Financial Prediction”. We prepare three feature sets – 3-D (Turkish equities), 7-D (S&P 500 index), and 64-D (selected U.S. stocks) – compute the required indicators, and set up expanding-window cross-validation. We then define classical baselines (shallow ANNs) and hybrid quantum neural networks (QNNs using PennyLane) with the specified encodings (angle for 3-D/7-D, amplitude for 64-D  ). Finally, we train and evaluate both models on each fold, reporting accuracy, AUC, and precision. |
| 4 | + |
| 5 | +import numpy as np |
| 6 | +import pandas as pd |
| 7 | +from sklearn.preprocessing import MinMaxScaler |
| 8 | +from sklearn.metrics import accuracy_score, precision_score, roc_auc_score |
| 9 | +import yfinance as yf |
| 10 | +import torch |
| 11 | +import torch.nn as nn |
| 12 | +import torch.optim as optim |
| 13 | +import pennylane as qml |
| 14 | + |
| 15 | +# Set random seed for reproducibility |
| 16 | +np.random.seed(0) |
| 17 | +torch.manual_seed(0) |
| 18 | + |
| 19 | +# --- Data Retrieval and Feature Construction --- |
| 20 | + |
| 21 | +# 1) Define tickers for each regime: |
| 22 | +turkish_tickers = ["KCHOL.IS", "GARAN.IS", "TUPRS.IS", "ULKER.IS", "TCELL.IS"] # Table IV |
| 23 | +indices_7d = ["^N225","^HSI","^AORD","^GDAXI","^FTSE","^DJI","^NYA"] # Indices for 7-D (Table V) |
| 24 | +# For S&P 500 target |
| 25 | +sp500_ticker = "^GSPC" |
| 26 | +us_tickers = ["AAPL", "BA", "GILD", "DVN", "LNC"] # Table III (U.S. stocks) |
| 27 | +# Eight global indices for 64-D (include S&P 500 as a proxy index) |
| 28 | +indices_64d = ["^N225","^HSI","^AORD","^GDAXI","^FTSE","^DJI","^NYA","^GSPC"] # assumed set |
| 29 | + |
| 30 | +# 2) Download data (2008-01-01 to 2021-12-31 to cover needed window) |
| 31 | +start_date = "2008-01-01" |
| 32 | +end_date = "2021-12-31" |
| 33 | +# Use Yahoo Finance via yfinance |
| 34 | +def fetch_data(tickers): |
| 35 | + data = yf.download(tickers, start=start_date, end=end_date)["Adj Close"] |
| 36 | + return data.dropna() |
| 37 | +# Fetch all relevant data |
| 38 | +turkish_data = fetch_data(turkish_tickers) |
| 39 | +indices_data = fetch_data(indices_7d + ["^GSPC"]) |
| 40 | +us_data = fetch_data(us_tickers + indices_64d) |
| 41 | + |
| 42 | +# 3) Feature engineering functions: |
| 43 | + |
| 44 | +def compute_RSI(series, window=14): |
| 45 | + """Compute RSI using Wilder’s smoothing (pandas_ta could be used in practice).""" |
| 46 | + delta = series.diff() |
| 47 | + gain = delta.clip(lower=0).fillna(0) |
| 48 | + loss = -delta.clip(upper=0).fillna(0) |
| 49 | + avg_gain = gain.rolling(window=window, min_periods=window).mean() |
| 50 | + avg_loss = loss.rolling(window=window, min_periods=window).mean() |
| 51 | + rs = avg_gain / (avg_loss + 1e-6) |
| 52 | + rsi = 100 - (100 / (1 + rs)) |
| 53 | + return rsi |
| 54 | + |
| 55 | +def compute_stochastic_K(series, window=14): |
| 56 | + """Compute %K (raw stochastic oscillator) over a rolling window.""" |
| 57 | + low_min = series.rolling(window=window, min_periods=window).min() |
| 58 | + high_max = series.rolling(window=window, min_periods=window).max() |
| 59 | + K = 100 * (series - low_min) / (high_max - low_min + 1e-6) |
| 60 | + return K |
| 61 | + |
| 62 | +# 4) Build feature sets and targets: |
| 63 | +# Example: For simplicity, we process one asset per regime here. |
| 64 | + |
| 65 | +# 4a) 3-D features for Turkish equity (take KCHOL.IS as example) |
| 66 | +close_KCHOL = turkish_data["KCHOL.IS"] |
| 67 | +rsi = compute_RSI(close_KCHOL, window=14) |
| 68 | +percentK = compute_stochastic_K(close_KCHOL, window=14) |
| 69 | +percentK_MA3 = percentK.rolling(window=3, min_periods=1).mean() |
| 70 | +df3 = pd.DataFrame({ |
| 71 | + "RSI14": rsi, |
| 72 | + "PctK14": percentK, |
| 73 | + "PctK_MA3": percentK_MA3 |
| 74 | +}) |
| 75 | +df3 = df3.dropna() |
| 76 | + |
| 77 | +# Create target: next-day return direction (1 if up, 0 if down) |
| 78 | +returns_KCHOL = close_KCHOL.pct_change().shift(-1) # next-day return |
| 79 | +target3 = (returns_KCHOL > 0).astype(int).loc[df3.index] |
| 80 | + |
| 81 | +# 4b) 7-D features for S&P 500 index |
| 82 | +# Compute log returns for indices |
| 83 | +logrets = np.log(indices_data).diff() |
| 84 | +# Align data for predictors (same-day returns of indices, lagged returns of DJI, NYA) |
| 85 | +feat7 = pd.DataFrame({ |
| 86 | + "N225": logrets["^N225"], |
| 87 | + "HSI": logrets["^HSI"], |
| 88 | + "AORD": logrets["^AORD"], |
| 89 | + "GDAXI": logrets["^GDAXI"], |
| 90 | + "FTSE": logrets["^FTSE"], |
| 91 | + # Use previous day returns for DJI and NYA |
| 92 | + "DJI_lag": logrets["^DJI"].shift(1), |
| 93 | + "NYA_lag": logrets["^NYA"].shift(1) |
| 94 | +}) |
| 95 | +# Drop NaNs |
| 96 | +feat7 = feat7.dropna() |
| 97 | +# Target: next-day direction of S&P 500 |
| 98 | +sp500_log = logrets["^GSPC"].loc[feat7.index] |
| 99 | +target7 = (sp500_log.shift(-1) > 0).astype(int).loc[feat7.index] |
| 100 | + |
| 101 | +# 4c) 64-D features for U.S. stocks (example: AAPL) |
| 102 | +# Compute daily returns for indices and stock |
| 103 | +us_rets = us_data.pct_change() |
| 104 | +stock = "AAPL" |
| 105 | +dates = us_rets.index |
| 106 | +# Prepare lagged features |
| 107 | +feat64_list = [] |
| 108 | +target64_list = [] |
| 109 | +for i in range(8, len(dates)): |
| 110 | + # indices returns for past 7 days (i-7 to i-1) |
| 111 | + inds_ret = [] |
| 112 | + for idx in indices_64d: |
| 113 | + inds_ret.extend(us_rets[idx].iloc[i-7:i].values) |
| 114 | + # own stock returns past 8 days (i-8 to i-1) |
| 115 | + stock_ret = us_rets[stock].iloc[i-8:i].values |
| 116 | + if np.isnan(inds_ret).any() or np.isnan(stock_ret).any(): |
| 117 | + continue |
| 118 | + feats = np.concatenate([inds_ret, stock_ret]) |
| 119 | + feat64_list.append(feats) |
| 120 | + # Target: next-day return direction |
| 121 | + ret_next = us_rets[stock].iloc[i] # daily return at day i (since 0-indexed, i corresponds to next day) |
| 122 | + target64_list.append(int(ret_next > 0)) |
| 123 | +# Create DataFrame |
| 124 | +feat64 = pd.DataFrame(feat64_list, index=dates[8: len(feat64_list)+8]) |
| 125 | +target64 = pd.Series(target64_list, index=feat64.index) |
| 126 | + |
| 127 | +# 5) Feature scaling (min-max on train folds only, as specified [oai_citation:2‡file_000000009ad472469e52759938248891](file://file_000000009ad472469e52759938248891#:~:text=2%29%20Feature%20Pre,pdf%20page%208%20of%2021)) |
| 128 | +# Define cross-validation splits (expanding window) |
| 129 | +# We will use end years for train and then test on following year(s) |
| 130 | +cv_splits = [ |
| 131 | + ("2010-01-01", "2014-12-31", "2015-01-01", "2015-12-31"), |
| 132 | + ("2010-01-01", "2015-12-31", "2016-01-01", "2016-12-31"), |
| 133 | + ("2010-01-01", "2016-12-31", "2017-01-01", "2017-12-31"), |
| 134 | + ("2010-01-01", "2017-12-31", "2018-01-01", "2018-12-31"), |
| 135 | + ("2010-01-01", "2018-12-31", "2019-01-01", "2021-12-31"), |
| 136 | +] |
| 137 | + |
| 138 | +# 6) Prepare models |
| 139 | +# --- Classical ANN baselines (using architectures from paper [oai_citation:3‡file_000000009ad472469e52759938248891](file://file_000000009ad472469e52759938248891#:~:text=%E2%80%A2%20Low,medium%29%2C%20and)) --- |
| 140 | +class FeedForwardNN(nn.Module): |
| 141 | + def __init__(self, layer_sizes): |
| 142 | + super().__init__() |
| 143 | + layers = [] |
| 144 | + for i in range(len(layer_sizes)-1): |
| 145 | + layers.append(nn.Linear(layer_sizes[i], layer_sizes[i+1])) |
| 146 | + # Use ReLU for hidden layers |
| 147 | + if i < len(layer_sizes)-2: |
| 148 | + layers.append(nn.ReLU()) |
| 149 | + layers.append(nn.Sigmoid()) # final output activation for binary |
| 150 | + self.net = nn.Sequential(*layers) |
| 151 | + def forward(self, x): |
| 152 | + return self.net(x) |
| 153 | + |
| 154 | +# --- Quantum Neural Network --- |
| 155 | +# We use PennyLane with PyTorch interface. |
| 156 | +def create_qnode(n_qubits, n_layers, angle_encoding=True): |
| 157 | + dev = qml.device("default.qubit", wires=n_qubits) |
| 158 | + # Weight shape: (n_layers, n_qubits) |
| 159 | + weight_shapes = {"weights": (n_layers, n_qubits)} |
| 160 | + @qml.qnode(dev, interface='torch', diff_method="backprop") |
| 161 | + def circuit(features, weights): |
| 162 | + # Encoding |
| 163 | + if angle_encoding: |
| 164 | + for i in range(n_qubits): |
| 165 | + qml.RY(features[i], wires=i) |
| 166 | + else: |
| 167 | + # Amplitude encoding (features length must be 2^n_qubits) |
| 168 | + qml.templates.AmplitudeEmbedding(features, wires=list(range(n_qubits)), normalize=True) |
| 169 | + # Variational layers |
| 170 | + for l in range(n_layers): |
| 171 | + for i in range(n_qubits): |
| 172 | + qml.RY(weights[l, i], wires=i) |
| 173 | + # Entangling layer (CNOT chain) |
| 174 | + for i in range(n_qubits-1): |
| 175 | + qml.CNOT(wires=[i, i+1]) |
| 176 | + # Return expectation values (all qubits, to allow MQR handling externally) |
| 177 | + return [qml.expval(qml.PauliZ(i)) for i in range(n_qubits)] |
| 178 | + return circuit, weight_shapes |
| 179 | + |
| 180 | +class QNN(nn.Module): |
| 181 | + def __init__(self, n_qubits, n_layers, angle_encoding=True, hybrid=False, multi_qubit_readout=False): |
| 182 | + super().__init__() |
| 183 | + self.n_qubits = n_qubits |
| 184 | + self.angle_encoding = angle_encoding |
| 185 | + self.hybrid = hybrid |
| 186 | + self.multi_qubit = multi_qubit_readout |
| 187 | + # If hybrid, a classical linear layer to project input to n_qubits |
| 188 | + if hybrid: |
| 189 | + self.pre = nn.Linear(None, n_qubits) # placeholder, input size to be set later |
| 190 | + # Quantum layer |
| 191 | + circuit, weight_shapes = create_qnode(n_qubits, n_layers, angle_encoding) |
| 192 | + self.qnode = qml.qnn.TorchLayer(circuit, weight_shapes) |
| 193 | + # If multi-qubit readout, aggregate via a linear layer |
| 194 | + if multi_qubit_readout: |
| 195 | + self.post = nn.Linear(n_qubits, 1) |
| 196 | + # If single-qubit readout, we will just take one output |
| 197 | + else: |
| 198 | + # Identity for consistency (output is scalar already) |
| 199 | + self.post = None |
| 200 | + |
| 201 | + def forward(self, x): |
| 202 | + # x: batch_size x d |
| 203 | + # Handle hybrid: project to n_qubits size if needed |
| 204 | + if self.hybrid: |
| 205 | + x = self.pre(x) |
| 206 | + # For amplitude encoding, ensure input has length 2^n_qubits |
| 207 | + # (Not implemented: assume x already appropriate length if amplitude used) |
| 208 | + # QNode expects a single sample, so apply individually |
| 209 | + outputs = [] |
| 210 | + for sample in x: |
| 211 | + out = self.qnode(sample) |
| 212 | + outputs.append(out) |
| 213 | + z = torch.stack(outputs) # shape (batch, n_qubits) |
| 214 | + # Readout |
| 215 | + if self.multi_qubit: |
| 216 | + z = self.post(z) # (batch, 1) |
| 217 | + z = torch.sigmoid(z).squeeze() |
| 218 | + else: |
| 219 | + # Take first qubit's expectation and scale to [0,1] |
| 220 | + z0 = (z[:,0] + 1) / 2 # scale expectation from [-1,1] to [0,1] |
| 221 | + z = z0 |
| 222 | + return z |
| 223 | + |
| 224 | +# Note: In practice, one would set the input size of self.pre after knowing feature dimension. |
| 225 | +# For simplicity, we assume hybrid=False in this code example when using QNN. |
| 226 | + |
| 227 | +# --- Training and Evaluation Loop --- |
| 228 | +def train_model(model, X_train, y_train, X_val=None, y_val=None, epochs=20, lr=0.01): |
| 229 | + criterion = nn.BCELoss() |
| 230 | + optimizer = optim.Adam(model.parameters(), lr=lr) |
| 231 | + for epoch in range(epochs): |
| 232 | + model.train() |
| 233 | + optimizer.zero_grad() |
| 234 | + outputs = model(torch.tensor(X_train, dtype=torch.float32)) |
| 235 | + loss = criterion(outputs, torch.tensor(y_train, dtype=torch.float32)) |
| 236 | + loss.backward() |
| 237 | + optimizer.step() |
| 238 | + # (Optionally compute validation loss here) |
| 239 | + return model |
| 240 | + |
| 241 | +def evaluate_model(model, X_test, y_test): |
| 242 | + model.eval() |
| 243 | + with torch.no_grad(): |
| 244 | + preds = model(torch.tensor(X_test, dtype=torch.float32)).numpy() |
| 245 | + # Predictions are probabilities; convert to binary using 0.5 threshold |
| 246 | + y_pred = (preds >= 0.5).astype(int) |
| 247 | + acc = accuracy_score(y_test, y_pred) |
| 248 | + prec = precision_score(y_test, y_pred, zero_division=0) |
| 249 | + auc = roc_auc_score(y_test, preds) |
| 250 | + return acc, prec, auc |
| 251 | + |
| 252 | +# Example training on fold 5 (final fold) for demonstration: |
| 253 | + |
| 254 | +# Select fold splits for final fold: |
| 255 | +train_start, train_end, test_start, test_end = cv_splits[-1] |
| 256 | +# 3-D data |
| 257 | +train_idx3 = (df3.index >= train_start) & (df3.index <= train_end) |
| 258 | +test_idx3 = (df3.index >= test_start) & (df3.index <= test_end) |
| 259 | +X3_train = df3.loc[train_idx3].values |
| 260 | +y3_train = target3.loc[train_idx3].values |
| 261 | +X3_test = df3.loc[test_idx3].values |
| 262 | +y3_test = target3.loc[test_idx3].values |
| 263 | +# Scale features |
| 264 | +scaler3 = MinMaxScaler() |
| 265 | +X3_train = scaler3.fit_transform(X3_train) |
| 266 | +X3_test = scaler3.transform(X3_test) |
| 267 | + |
| 268 | +# ANN baseline for 3-D: [3-11-1] |
| 269 | +ann3 = FeedForwardNN([3, 11, 1]) |
| 270 | +train_model(ann3, X3_train, y3_train, epochs=50, lr=0.01) |
| 271 | +acc_ann3, prec_ann3, auc_ann3 = evaluate_model(ann3, X3_test, y3_test) |
| 272 | + |
| 273 | +# QNN for 3-D: angle encoding, no hybrid, single-qubit readout (3 qubits, try depth=3) |
| 274 | +qnn3 = QNN(n_qubits=3, n_layers=3, angle_encoding=True, hybrid=False, multi_qubit_readout=False) |
| 275 | +acc_qnn3, prec_qnn3, auc_qnn3 = evaluate_model(qnn3, X3_test, y3_test) |
| 276 | + |
| 277 | +print("3-D (Turkish): ANN vs QNN -> Acc: {:.3f}/{:.3f}, AUC: {:.3f}/{:.3f}, Prec: {:.3f}/{:.3f}".format( |
| 278 | + acc_ann3, acc_qnn3, auc_ann3, auc_qnn3, prec_ann3, prec_qnn3)) |
| 279 | + |
| 280 | +# Similar steps would be followed for 7-D and 64-D sets, including grid search over depth/qubits. |
| 281 | + |
| 282 | +Notes on the implementation: We follow the paper’s setup closely. For example, the 3-D features (RSI-14, %K14, and 3-day moving average of %K14) are constructed from Turkish stock prices . The 7-D features use same-day log-returns of global indices (Nikkei, Hang Seng, etc.) and lagged U.S. indices . The 64-D regime concatenates 7-day lagged returns of 8 indices and 8 past returns of each U.S. stock . All features are scaled to [0,1] using MinMax scaling (fit on the training fold only ). An expanding “walk-forward” cross-validation with 5 folds is used . |
| 283 | + |
| 284 | +For the quantum models, angle encoding is used for the 3-D and 7-D cases (one qubit per feature) while amplitude encoding is used for the 64-D case with qubit count as a hyperparameter . We define QNN variants as PyTorch modules using PennyLane’s TorchLayer. In the example above, we show a single-qubit readout QNN for the 3-D case; multi-qubit readout variants could be implemented by aggregating all qubit measurements with a classical layer. Classical ANNs are kept shallow as in the paper (e.g. [3–11–1] for 3-D, [7–32–16–1] for 7-D, [64–32–1] for 64-D ). |
| 285 | + |
| 286 | +Finally, both models are trained on each training fold, with hyperparameters (e.g. circuit depth, qubit count for amplitude encoding) selected by validation AUC . We evaluate on the test fold and report accuracy, AUC, and precision. The example printout compares the ANN and QNN on the final fold of the 3-D task; analogous results can be obtained for the 7-D and 64-D regimes. |
0 commit comments