Tensor Network for Anomaly Detection in Latent Space of Proton-Proton Collision Events at the LHC#

Implementation of the anomaly detection pipeline from the paper: 10.1088/2632-2153/ae0243.

Install tn4ml directly from GitHub:

git clone https://github.com/bsc-quantic/tn4ml.git

Install an additional package for data handling:

  • h5py

(optional) Download dataset#

The dimensionality of the dataset is reduced by passing it through autoencoder. If you are interested more in the autoencoder’s architecture, please refer to [*].

Reduced dataset can be downloaded from Zenodo:

record/7673769

Description of filenames:

  • latentrep_QCD_sig.h5: train dataset (QCD - background)

  • latentrep_QCD_sig_testclustering.h5: test dataset (QCD - background)

  • latentrep_RSGraviton_WW_NA_35.h5: test dataset (Signal $mathrm{NA G rightarrow WW}$)

Or it can be downloaded directly in the pipeline.py.

Run training and evaluation pipeline#

Data Parameters

  • save_dir (str): Path to directory for saving results (default = "results/")

  • load_dir (str): Path to directory for loading the data

  • feature_range (list): Feature range for scaling (default = [0, 1])

  • seed (int): Seed for random number generator

  • standardization (str): Standardization of data ("yes" or "no", default = "yes")

  • minmax (str): Minmax scaling of data ("yes" or "no", default = "yes")

  • embedding (str): Embedding type for input data (e.g., "legendre_4", "fourier_2", "hermite_3")

  • test_size (int): Number of samples for testing

  • train_size (int): Number of samples for training

  • signal_name (str): Name of signal dataset (default = "RSGraviton_WW_NA_35")

  • latent (int): Latent space dimension

MPS Parameters

  • bond_dim (int): Bond dimension of MPS (default = 5)

  • initializer (str): Type of MPS initialization

  • shape_method (str): Method for distributing bond dimensions (default = "even")

Training Parameters

  • lr (float): Learning rate (default = 1e-3)

  • min_delta (float): Minimum improvement required for early stopping (default = 0)

  • patience (int): Number of epochs with no improvement before early stopping (default = 20)

  • epochs (int): Maximum number of training epochs (default = 100)

  • batch_size (int): Number of samples per training batch (default = 32)

  • run (int): Number of training repetitions with different seeds

python pipeline.py -save_dir results/ \
                   -load_dir QML_paper_data \
                   -feature_range 0 1 \
                   -minmax yes \
                   -embedding laguerre_2 \
                   -test_size 5000 \
                   -train_size 10000 \
                   -bond_dim 8 \
                   -initializer unitary \
                   -lr 0.001 \
                   -patience 25 \
                   -epochs 100 \
                   -batch_size 128 \
                   -run 1 \
                   -latent 4

Run evaluation only#

Data Parameters

  • save_dir (str): Path to directory for saving results (default = "results/")

  • load_dir (str): Path to directory for loading the data

  • feature_range (list): Feature range for scaling (default = [0, 1])

  • seed (int): Seed for random number generator

  • standardization (str): Standardization of data ("yes" or "no", default = "yes")

  • minmax (str): Minmax scaling of data ("yes" or "no", default = "yes")

  • embedding (str): Embedding type for input data (e.g., "legendre_4", "fourier_2", "hermite_3")

  • test_size (int): Number of samples for testing

  • signal_name (str): Name of signal dataset (default = "RSGraviton_WW_NA_35", options: "RSGraviton_WW_BR_15", "AtoHZ_to_ZZZ_35")

  • latent (int): Latent space dimension

MPS Parameters

  • bond_dim (int): Bond dimension of MPS (default = 5)

  • initializer (str): Type of MPS initialization

Training Parameters

  • batch_size (int): Number of samples per training batch (default = 32)

  • run (int): Number of training repetitions with different seeds

python evaluation.py -save_dir results/ \
                   -load_dir QML_paper_data \
                   -feature_range 0 1 \
                   -minmax yes \
                   -embedding laguerre_2 \
                   -test_size 5000 \
                   -bond_dim 8 \
                   -initializer unitary \
                   -batch_size 128 \
                   -run 1 \
                   -latent 4

Additional Example Scripts#

Below are the Python scripts used in this example. You can view their contents directly on this page, or access them on GitHub.

  • evaluation.py: evaluation.py View on GitHub

    evaluation.py#
      1import argparse
      2import os
      3
      4import h5py
      5import jax
      6import jax.numpy as jnp
      7import joblib
      8import numpy as np
      9from utils import (
     10    Colors,
     11    _ensure_data_exists,
     12    calc_fidelity_batch,
     13    load_test_data,
     14)
     15
     16import tn4ml
     17from tn4ml.embeddings import (
     18    Embedding,
     19    FourierEmbedding,
     20    HermiteEmbedding,
     21    LaguerreEmbedding,
     22    LegendreEmbedding,
     23)
     24from tn4ml.initializers import gramschmidt, rand_unitary
     25from tn4ml.models.model import load_model
     26
     27if __name__ == "__main__":
     28    parser = argparse.ArgumentParser(
     29        description="read arguments for training of TN model"
     30    )
     31    parser.add_argument(
     32        "-save_dir",
     33        dest="save_dir",
     34        type=str,
     35        help="path to directory for saving results",
     36        default="results/",
     37    )
     38    parser.add_argument(
     39        "-load_dir",
     40        dest="load_dir",
     41        type=str,
     42        help="path to directory for loading the data",
     43    )
     44
     45    # data params
     46    parser.add_argument(
     47        "-feature_range",
     48        dest="feature_range",
     49        type=float,
     50        nargs=2,
     51        default=[0, 1],
     52        help="Feature range for scaling",
     53    )
     54    parser.add_argument(
     55        "-seed", dest="seed", type=int, help="Seed for random number generator"
     56    )
     57    parser.add_argument(
     58        "-standardization",
     59        dest="standardization",
     60        type=str,
     61        default="yes",
     62        choices=["yes", "no"],
     63        help="Standardization of data",
     64    )
     65    parser.add_argument(
     66        "-minmax",
     67        dest="minmax",
     68        type=str,
     69        default="yes",
     70        choices=["yes", "no"],
     71        help="Minmax scaling of data",
     72    )
     73    parser.add_argument(
     74        "-embedding", dest="embedding", type=str, help="Embedding type for input data"
     75    )
     76    parser.add_argument("-test_size", dest="test_size", type=int, help="Test size")
     77    parser.add_argument(
     78        "-signal_name",
     79        dest="signal_name",
     80        type=str,
     81        default="RSGraviton_WW_NA_35",
     82        help="Name of signal",
     83    )
     84
     85    # MPS params
     86    parser.add_argument(
     87        "-bond_dim", dest="bond_dim", type=int, default=5, help="Bond dimension"
     88    )
     89    parser.add_argument(
     90        "-initializer", dest="initializer", type=str, help="Type of MPS initialization"
     91    )
     92
     93    # testing params
     94    parser.add_argument("-batch_size", dest="batch_size", type=int, default=32)
     95    parser.add_argument(
     96        "-run", dest="run", type=int, help="Number of training repetitions"
     97    )
     98
     99    parser.add_argument(
    100        "-latent", dest="latent", type=int, help="Latent space dimension"
    101    )
    102
    103    args = parser.parse_args()
    104    params = vars(args)
    105
    106    # Get paths to all data files, downloading them if necessary
    107    print(
    108        Colors.YELLOW.value + "Checking data folder..." + Colors.RESET.value + "\n",
    109        end="",
    110    )
    111
    112    _ensure_data_exists(args.load_dir, args.latent)
    113
    114    print(Colors.BLUE.value + "Importing data... " + Colors.RESET.value + "\n", end="")
    115
    116    if args.standardization == "yes":
    117        save_dir = (
    118            args.save_dir
    119            + "/"
    120            + args.initializer
    121            + "/10k_standard/"
    122            + str(args.embedding)
    123            + "/lat"
    124            + str(args.latent)
    125            + "/bond"
    126            + str(args.bond_dim)
    127            + "/run_"
    128            + str(args.run)
    129        )
    130    elif args.minmax == "yes":
    131        if tuple(args.feature_range) == (-1, 1):
    132            save_dir = (
    133                args.save_dir
    134                + "/"
    135                + args.initializer
    136                + "/10k_minmax-11/"
    137                + str(args.embedding)
    138                + "/lat"
    139                + str(args.latent)
    140                + "/bond"
    141                + str(args.bond_dim)
    142                + "/run_"
    143                + str(args.run)
    144            )
    145        else:
    146            save_dir = (
    147                args.save_dir
    148                + "/"
    149                + args.initializer
    150                + "/10k_minmax01/"
    151                + str(args.embedding)
    152                + "/lat"
    153                + str(args.latent)
    154                + "/bond"
    155                + str(args.bond_dim)
    156                + "/run_"
    157                + str(args.run)
    158            )
    159    else:
    160        save_dir = (
    161            args.save_dir
    162            + "/"
    163            + args.initializer
    164            + "/10k/"
    165            + str(args.embedding)
    166            + "/lat"
    167            + str(args.latent)
    168            + "/bond"
    169            + str(args.bond_dim)
    170            + "/run_"
    171            + str(args.run)
    172        )
    173
    174    # set standardization and minmax to bool
    175    standardization = args.standardization == "yes"
    176
    177    minmax = args.minmax == "yes"
    178
    179    # check result dir
    180    if not os.path.exists(save_dir):  # noqa: PTH110
    181        # Create a new directory because it does not exist
    182        os.makedirs(save_dir)  # noqa: PTH103
    183
    184    if args.seed is not None:
    185        # Use specified seed for reproducibility
    186        seed = args.seed
    187        print(
    188            Colors.YELLOW.value
    189            + f"Using specified seed: {seed}"
    190            + Colors.RESET.value
    191            + "\n",
    192            end="",
    193        )
    194    else:
    195        # Generate random seed for exploration
    196        seed = int.from_bytes(os.urandom(4), "big")
    197        print(
    198            Colors.YELLOW.value
    199            + f"Using random seed: {seed}"
    200            + Colors.RESET.value
    201            + "\n",
    202            end="",
    203        )
    204
    205    # Set random seed
    206    np.random.seed(seed)  # noqa: NPY002
    207    key = jax.random.PRNGKey(seed)
    208
    209    # Set JAX to use 64-bit precision
    210    # This is important for numerical stability in some cases
    211    jax.config.update("jax_enable_x64", True)
    212
    213    # Parse embedding string to get type and degree
    214    embedding_string = args.embedding
    215    try:
    216        embedding_type, degree_str = embedding_string.split("_", 1)
    217        degree = int(degree_str)
    218    except ValueError:
    219        raise ValueError(
    220            Colors.RED.value
    221            + f"Invalid embedding format: {embedding_string}. Expected format: 'name_degree' (e.g., 'fourier_2')"
    222            + Colors.RESET.value
    223        ) from None
    224
    225    # Initialize embedding based on type and degree
    226    if embedding_type == "fourier":
    227        phys_dim = (
    228            degree * 2
    229        )  # Each frequency component adds 2 dimensions (sin and cos)
    230        embedding: Embedding = FourierEmbedding(p=degree)
    231    elif embedding_type == "legendre":
    232        phys_dim = degree + 1  # Legendre polynomials from degree 0 to degree
    233        embedding = LegendreEmbedding(degree=degree)
    234    elif embedding_type == "laguerre":
    235        phys_dim = degree + 1  # Laguerre polynomials from degree 0 to degree
    236        embedding = LaguerreEmbedding(degree=degree)
    237    elif embedding_type == "hermite":
    238        phys_dim = degree + 1  # Hermite polynomials from degree 0 to degree
    239        embedding = HermiteEmbedding(degree=degree)
    240    else:
    241        raise ValueError(
    242            Colors.RED.value
    243            + f"Invalid embedding type: {embedding_type}. Supported types: fourier, legendre, laguerre, hermite"
    244            + Colors.RESET.value
    245        )
    246
    247    # Set the standard deviation for the initializer
    248    # This is a heuristic value based on the bond dimension and physical dimension from the paper https://arxiv.org/abs/2310.20498
    249    std = np.power(float(phys_dim * args.bond_dim), -1)
    250
    251    # Define the possible initializers
    252    initializers = {
    253        "gramschmidt_n_std": gramschmidt("normal", std, dtype=jnp.float64),
    254        "randn_std": tn4ml.initializers.randn(std),
    255        "randn_1e-2": tn4ml.initializers.randn(1e-2),
    256        "unitary": rand_unitary(),
    257    }
    258
    259    # Check if the initializer is valid
    260    if args.initializer not in initializers:
    261        raise ValueError(
    262            Colors.RED.value
    263            + f"Invalid initializer: {args.initializer}. Supported initializers: {', '.join(initializers.keys())}"
    264            + Colors.RESET.value
    265        )
    266
    267    print(
    268        Colors.BLUE.value + "Loading the model..." + Colors.RESET.value + "\n", end=""
    269    )
    270    # Save model
    271    model_name = "model"
    272    model = load_model(model_name, save_dir)
    273
    274    # EVALUATION
    275    print(Colors.BLUE.value + "Evaluating model..." + Colors.RESET.value + "\n", end="")
    276
    277    # Load scalers
    278    prefix = "train_qcd"
    279    if args.standardization == "yes":
    280        scaler_path = os.path.join(save_dir, f"scaler_standard_{prefix}.pkl")  # noqa: PTH118
    281        if os.path.exists(scaler_path):  # noqa: PTH110
    282            with open(scaler_path, "rb") as f:  # noqa: PTH123
    283                scaler = joblib.load(f)
    284        else:
    285            raise FileNotFoundError(
    286                Colors.RED.value
    287                + f"Scaler file not found: {scaler_path}"
    288                + Colors.RESET.value
    289            )
    290    else:
    291        scaler = None
    292
    293    if args.minmax == "yes":
    294        scaler_path = os.path.join(save_dir, f"scaler_minmax_{prefix}.pkl")  # noqa: PTH118
    295        if os.path.exists(scaler_path):  # noqa: PTH110
    296            with open(scaler_path, "rb") as f:  # noqa: PTH123
    297                min_max_scaler = joblib.load(f)
    298        else:
    299            raise FileNotFoundError(
    300                Colors.RED.value
    301                + f"Scaler file not found: {scaler_path}"
    302                + Colors.RESET.value
    303            )
    304    else:
    305        min_max_scaler = None
    306
    307    # Load test data
    308    read_data_dir = f"{args.load_dir}/latent{args.latent}"
    309    qcd_test_scaled = load_test_data(
    310        f"{read_data_dir}",
    311        dataset_type="qcd",
    312        scaler=scaler,
    313        min_max_scaler=min_max_scaler,
    314        test_size=args.test_size,
    315        shuffle_seed=seed,
    316    )
    317
    318    sig_test_scaled = load_test_data(
    319        f"{read_data_dir}/latentrep_{args.signal_name}.h5",
    320        dataset_type="signal",
    321        scaler=scaler,
    322        min_max_scaler=min_max_scaler,
    323        test_size=args.test_size,
    324        shuffle_seed=seed,
    325    )
    326
    327    # Calculate Fidelity - AD score
    328    print(
    329        Colors.YELLOW.value
    330        + "Calculating fidelity scores..."
    331        + Colors.RESET.value
    332        + "\n",
    333        end="",
    334    )
    335
    336    fid_qcd = calc_fidelity_batch(
    337        qcd_test_scaled, model, embedding=embedding, batch_size=args.batch_size
    338    )
    339
    340    fid_sig = calc_fidelity_batch(
    341        sig_test_scaled, model, embedding=embedding, batch_size=args.batch_size
    342    )
    343
    344    # Save anomaly scores
    345    print(
    346        Colors.BLUE.value + "Saving fidelity scores..." + Colors.RESET.value + "\n",
    347        end="",
    348    )
    349    with h5py.File(f"{save_dir}/fidelity_scores_{args.signal_name}.h5", "w") as file:
    350        file.create_dataset("loss_qcd", data=fid_qcd)
    351        file.create_dataset("loss_sig", data=fid_sig)
    
  • pipeline.py: * View in browser: pipeline.py * View on GitHub

    pipeline.py#
      1import argparse
      2import json
      3import os
      4
      5import h5py
      6import jax
      7import jax.numpy as jnp
      8import matplotlib.pyplot as plt
      9import numpy as np
     10import optax
     11from utils import (
     12    Colors,
     13    _ensure_data_exists,
     14    calc_fidelity_batch,
     15    load_test_data,
     16    load_train_data,
     17)
     18
     19import tn4ml
     20from tn4ml.embeddings import (
     21    Embedding,
     22    FourierEmbedding,
     23    HermiteEmbedding,
     24    LaguerreEmbedding,
     25    LegendreEmbedding,
     26)
     27from tn4ml.initializers import gramschmidt, rand_unitary
     28from tn4ml.metrics import NegLogLikelihood
     29from tn4ml.models.mps import MPS_initialize
     30from tn4ml.util import EarlyStopping, TrainingType
     31
     32if __name__ == "__main__":
     33    parser = argparse.ArgumentParser(
     34        description="read arguments for training of TN model"
     35    )
     36    parser.add_argument(
     37        "-save_dir",
     38        dest="save_dir",
     39        type=str,
     40        help="path to directory for saving results",
     41        default="results/",
     42    )
     43    parser.add_argument(
     44        "-load_dir",
     45        dest="load_dir",
     46        type=str,
     47        help="path to directory for loading the data",
     48    )
     49
     50    # data params
     51    parser.add_argument(
     52        "-feature_range",
     53        dest="feature_range",
     54        type=float,
     55        nargs=2,
     56        default=[0, 1],
     57        help="Feature range for scaling",
     58    )
     59    parser.add_argument(
     60        "-seed", dest="seed", type=int, help="Seed for random number generator"
     61    )
     62    parser.add_argument(
     63        "-standardization",
     64        dest="standardization",
     65        type=str,
     66        default="yes",
     67        choices=["yes", "no"],
     68        help="Standardization of data",
     69    )
     70    parser.add_argument(
     71        "-minmax",
     72        dest="minmax",
     73        type=str,
     74        default="yes",
     75        choices=["yes", "no"],
     76        help="Minmax scaling of data",
     77    )
     78    parser.add_argument(
     79        "-embedding", dest="embedding", type=str, help="Embedding type for input data"
     80    )
     81    parser.add_argument("-test_size", dest="test_size", type=int, help="Test size")
     82    parser.add_argument("-train_size", dest="train_size", type=int, help="Train size")
     83    parser.add_argument(
     84        "-signal_name",
     85        dest="signal_name",
     86        type=str,
     87        default="RSGraviton_WW_NA_35",
     88        help="Name of signal",
     89    )
     90
     91    # MPS params
     92    parser.add_argument(
     93        "-bond_dim", dest="bond_dim", type=int, default=5, help="Bond dimension"
     94    )
     95    parser.add_argument(
     96        "-initializer", dest="initializer", type=str, help="Type of MPS initialization"
     97    )
     98    parser.add_argument("-shape_method", dest="shape_method", type=str, default="even")
     99
    100    # training params
    101    parser.add_argument("-lr", dest="lr", type=float, default=1e-3)
    102    parser.add_argument("-min_delta", dest="min_delta", type=float, default=0)
    103    parser.add_argument("-patience", dest="patience", type=int, default=20)
    104    parser.add_argument("-epochs", dest="epochs", type=int, default=100)
    105    parser.add_argument("-batch_size", dest="batch_size", type=int, default=32)
    106    parser.add_argument(
    107        "-run", dest="run", type=int, help="Number of training repetitions"
    108    )
    109
    110    parser.add_argument(
    111        "-latent", dest="latent", type=int, help="Latent space dimension"
    112    )
    113
    114    args = parser.parse_args()
    115    params = vars(args)
    116
    117    # Get paths to all data files, downloading them if necessary
    118    print(
    119        Colors.YELLOW.value + "Checking data folder..." + Colors.RESET.value + "\n",
    120        end="",
    121    )
    122
    123    _ensure_data_exists(args.load_dir, args.latent)
    124
    125    print(Colors.BLUE.value + "Importing data... " + Colors.RESET.value + "\n", end="")
    126
    127    if args.standardization == "yes":
    128        save_dir = (
    129            args.save_dir
    130            + "/"
    131            + args.initializer
    132            + "/10k_standard/"
    133            + str(args.embedding)
    134            + "/lat"
    135            + str(args.latent)
    136            + "/bond"
    137            + str(args.bond_dim)
    138            + "/run_"
    139            + str(args.run)
    140        )
    141    elif args.minmax == "yes":
    142        if tuple(args.feature_range) == (-1, 1):
    143            save_dir = (
    144                args.save_dir
    145                + "/"
    146                + args.initializer
    147                + "/10k_minmax-11/"
    148                + str(args.embedding)
    149                + "/lat"
    150                + str(args.latent)
    151                + "/bond"
    152                + str(args.bond_dim)
    153                + "/run_"
    154                + str(args.run)
    155            )
    156        else:
    157            save_dir = (
    158                args.save_dir
    159                + "/"
    160                + args.initializer
    161                + "/10k_minmax01/"
    162                + str(args.embedding)
    163                + "/lat"
    164                + str(args.latent)
    165                + "/bond"
    166                + str(args.bond_dim)
    167                + "/run_"
    168                + str(args.run)
    169            )
    170    else:
    171        save_dir = (
    172            args.save_dir
    173            + "/"
    174            + args.initializer
    175            + "/10k/"
    176            + str(args.embedding)
    177            + "/lat"
    178            + str(args.latent)
    179            + "/bond"
    180            + str(args.bond_dim)
    181            + "/run_"
    182            + str(args.run)
    183        )
    184
    185    # set standardization and minmax to bool
    186    standardization = args.standardization == "yes"
    187
    188    minmax = args.minmax == "yes"
    189
    190    # check result dir
    191    if not os.path.exists(save_dir):  # noqa: PTH110
    192        # Create a new directory because it does not exist
    193        os.makedirs(save_dir)  # noqa: PTH103
    194
    195    if args.seed is not None:
    196        # Use specified seed for reproducibility
    197        seed = args.seed
    198        print(
    199            Colors.BLUE.value
    200            + f"Using specified seed: {seed}"
    201            + Colors.RESET.value
    202            + "\n",
    203            end="",
    204        )
    205    else:
    206        # Generate random seed for exploration
    207        seed = int.from_bytes(os.urandom(4), "big")
    208        print(
    209            Colors.BLUE.value
    210            + f"Using random seed: {seed}"
    211            + Colors.RESET.value
    212            + "\n",
    213            end="",
    214        )
    215
    216    # Set random seed
    217    np.random.seed(seed)  # noqa: NPY002
    218    key = jax.random.PRNGKey(seed)
    219
    220    # Set JAX to use 64-bit precision
    221    # This is important for numerical stability in some cases
    222    jax.config.update("jax_enable_x64", True)
    223
    224    # Load_data
    225    read_data_file = f"{args.load_dir}/latent{args.latent}/latentrep_QCD_sig.h5"
    226    train_data, scalers = load_train_data(
    227        read_data_file,
    228        args.train_size,
    229        minmax,
    230        standardization,
    231        feature_range=tuple(args.feature_range),
    232        shuffle_seed=seed,
    233        save_dir=save_dir,
    234    )
    235
    236    # Create a MPS model
    237    L = train_data.shape[1]
    238    print(
    239        Colors.BLUE.value + "Number of tensors: " + str(L) + Colors.RESET.value + "\n",
    240        end="",
    241    )
    242
    243    # Parse embedding string to get type and degree
    244    embedding_string = args.embedding
    245    try:
    246        embedding_type, degree_str = embedding_string.split("_", 1)
    247        degree = int(degree_str)
    248    except ValueError:
    249        raise ValueError(  # noqa: B904
    250            f"Invalid embedding format: {embedding_string}. Expected format: 'name_degree' (e.g., 'fourier_2')"
    251        )
    252
    253    # Initialize embedding based on type and degree
    254    if embedding_type == "fourier":
    255        phys_dim = (
    256            degree * 2
    257        )  # Each frequency component adds 2 dimensions (sin and cos)
    258        embedding: Embedding = FourierEmbedding(p=degree)
    259    elif embedding_type == "legendre":
    260        phys_dim = degree + 1  # Legendre polynomials from degree 0 to degree
    261        embedding = LegendreEmbedding(degree=degree)
    262    elif embedding_type == "laguerre":
    263        phys_dim = degree + 1  # Laguerre polynomials from degree 0 to degree
    264        embedding = LaguerreEmbedding(degree=degree)
    265    elif embedding_type == "hermite":
    266        phys_dim = degree + 1  # Hermite polynomials from degree 0 to degree
    267        embedding = HermiteEmbedding(degree=degree)
    268    else:
    269        raise ValueError(
    270            f"Invalid embedding type: {embedding_type}. Supported types: fourier, legendre, laguerre, hermite"
    271        )
    272
    273    print(
    274        Colors.BLUE.value
    275        + f"Using {embedding_type} embedding with degree {degree} (physical dimension: {phys_dim})"
    276        + Colors.RESET.value
    277        + "\n",
    278        end="",
    279    )
    280
    281    # Set the standard deviation for the initializer
    282    # This is a heuristic value based on the bond dimension and physical dimension from the paper https://arxiv.org/abs/2310.20498
    283    std = np.power(float(phys_dim * args.bond_dim), -1)
    284
    285    # Define the possible initializers
    286    initializers = {
    287        "gramschmidt_n_std": gramschmidt("normal", std, dtype=jnp.float64),
    288        "randn_std": tn4ml.initializers.randn(std),
    289        "randn_1e-2": tn4ml.initializers.randn(1e-2),
    290        "unitary": rand_unitary(),
    291    }
    292
    293    # Check if the initializer is valid
    294    if args.initializer not in initializers:
    295        raise ValueError(
    296            f"Invalid initializer: {args.initializer}. Supported initializers: {', '.join(initializers.keys())}"
    297        )
    298
    299    # Initialize the MPS model
    300    shape_method = (
    301        args.shape_method
    302    )  # shape method defines how the tensors are arranged in the MPS
    303    compress = (
    304        False  # compress the MPS tensors - not used in this example, feature in quimb
    305    )
    306    add_identity = False  # option to add identity tensors to the MPS
    307    canonical_center = 0  # canonical center at the first tensor
    308    canonize = (True, 0)  # flag to canonize the MPS tensors during training
    309
    310    print(
    311        Colors.BLUE.value + "Initializing MPS model..." + Colors.RESET.value + "\n",
    312        end="",
    313    )
    314    model = MPS_initialize(
    315        L=L,
    316        initializer=initializers[args.initializer],
    317        key=key,
    318        shape_method=shape_method,
    319        bond_dim=args.bond_dim,
    320        phys_dim=phys_dim,
    321        cyclic=False,
    322        compress=compress,
    323        add_identity=add_identity,
    324        canonical_center=canonical_center,
    325        boundary="obc",
    326    )
    327
    328    # Define training parameters
    329    optimizer = optax.adam
    330    strategy = "global"
    331    loss = NegLogLikelihood
    332    train_type = TrainingType.UNSUPERVISED
    333    learning_rate = args.lr
    334
    335    # Configure the model with the optimizer, strategy, loss function, and training type
    336    model.configure(
    337        optimizer=optimizer,
    338        strategy=strategy,
    339        loss=loss,
    340        train_type=train_type,
    341        learning_rate=learning_rate,
    342    )
    343
    344    # Initialize the early stopping callback
    345    earlystop = EarlyStopping(
    346        min_delta=args.min_delta, patience=args.patience, mode="min", monitor="loss"
    347    )
    348
    349    # Train the model
    350    print(Colors.BLUE.value + "Training model..." + Colors.RESET.value + "\n", end="")
    351    history = model.train(
    352        train_data,
    353        epochs=args.epochs,
    354        batch_size=args.batch_size,
    355        embedding=embedding,
    356        normalize=True,
    357        dtype=jnp.float64,
    358        earlystop=earlystop,
    359        canonize=canonize,
    360        seed=seed,
    361        shuffle=True,
    362    )
    363
    364    # -------- SAVE RESULTS AND MODEL -------------
    365
    366    print(Colors.BLUE.value + "Saving the model..." + Colors.RESET.value + "\n", end="")
    367    # Save model
    368    model_name = "model"
    369    model.save(model_name, save_dir)
    370
    371    # Plot loss
    372    plt.figure()
    373    plt.plot(range(len(history["loss"])), history["loss"], label="train")
    374    plt.legend()
    375    plt.savefig(save_dir + "/loss.pdf")
    376
    377    # Save loss
    378    np.save(save_dir + "/loss.npy", history["loss"])
    379
    380    params_save = {
    381        # MPS parameters
    382        "bond_dim": str(args.bond_dim),
    383        "phys_dim": str(phys_dim),
    384        "initializer": str(args.initializer),
    385        "shape_method": str(shape_method),
    386        "compress": str(compress),
    387        "add_identity": str(add_identity),
    388        "boundary": "obc",
    389        "std": str(std),
    390        # Data parameters
    391        "embedding": str(embedding_string),
    392        "train_size": str(args.train_size),
    393        "test_size": str(args.test_size),
    394        "signal_name": str(args.signal_name),
    395        "feature_range": str(args.feature_range),
    396        "standardization": str(args.standardization),
    397        "minmax": str(args.minmax),
    398        # Training parameters
    399        "learning_rate": str(args.lr),
    400        "batch_size": str(args.batch_size),
    401        "epochs": str(args.epochs),
    402        "patience": str(args.patience),
    403        "min_delta": str(args.min_delta),
    404        # Model configuration
    405        "strategy": strategy,
    406        "optimizer": "adam",
    407        "loss": "NegLogLikelihood",
    408        "train_type": "unsupervised",
    409        # Seed and paths
    410        "seed": str(seed),
    411        "save_dir": save_dir,
    412        "load_dir": args.load_dir,
    413        "latent_space_dim": str(args.latent),
    414    }
    415
    416    # Save parameters
    417    print(
    418        Colors.BLUE.value + "Saving parameters..." + Colors.RESET.value + "\n", end=""
    419    )
    420    with open(os.path.join(save_dir, "parameters.json"), "w") as f:  # noqa: PTH118, PTH123
    421        json.dump(params_save, f, indent=4)
    422
    423    # EVALUATION
    424    print(Colors.BLUE.value + "Evaluating model..." + Colors.RESET.value + "\n", end="")
    425
    426    scaler = scalers["standard"] if args.standardization == "yes" else None
    427
    428    min_max_scaler = scalers["minmax"] if args.minmax == "yes" else None
    429
    430    # Load test data
    431    read_data_dir = f"{args.load_dir}/latent{args.latent}"
    432    qcd_test_scaled = load_test_data(
    433        f"{read_data_dir}",
    434        dataset_type="qcd",
    435        scaler=scaler,
    436        min_max_scaler=min_max_scaler,
    437        test_size=args.test_size,
    438        shuffle_seed=seed,
    439    )
    440
    441    sig_test_scaled = load_test_data(
    442        f"{read_data_dir}/latentrep_{args.signal_name}.h5",
    443        dataset_type="signal",
    444        scaler=scaler,
    445        min_max_scaler=min_max_scaler,
    446        test_size=args.test_size,
    447        shuffle_seed=seed,
    448    )
    449
    450    # Calculate Fidelity - AD score
    451    print(
    452        Colors.YELLOW.value
    453        + "Calculating fidelity scores..."
    454        + Colors.RESET.value
    455        + "\n",
    456        end="",
    457    )
    458
    459    fid_qcd = calc_fidelity_batch(
    460        qcd_test_scaled, model, embedding=embedding, batch_size=args.batch_size
    461    )
    462
    463    fid_sig = calc_fidelity_batch(
    464        sig_test_scaled, model, embedding=embedding, batch_size=args.batch_size
    465    )
    466
    467    # Save anomaly scores
    468    print(
    469        Colors.BLUE.value + "Saving fidelity scores..." + Colors.RESET.value + "\n",
    470        end="",
    471    )
    472    with h5py.File(f"{save_dir}/fidelity_scores_{args.signal_name}.h5", "w") as file:
    473        file.create_dataset("loss_qcd", data=fid_qcd)
    474        file.create_dataset("loss_sig", data=fid_sig)
    
  • plot.py: * View in browser: plot.py * View on GitHub

    plot.py#
       1import os
       2from collections.abc import Collection
       3
       4import h5py
       5import matplotlib.patches as mpatches
       6import matplotlib.pyplot as plt
       7import numpy as np
       8from matplotlib.font_manager import FontProperties
       9from sklearn.metrics import auc
      10from utils import *
      11
      12from tn4ml.eval import *
      13
      14
      15def load_anomaly_scores(
      16    signal_name: str,
      17    initializers_strings: Collection[str],
      18    latent_spaces: Collection[int],
      19    bond_dim: dict,
      20    embedding: str,
      21    nruns: int,
      22    save_dir: str,
      23    train_scaling: str,
      24):
      25    """
      26    Load anomaly scores for different initializers and bond dimensions.
      27
      28    Parameters
      29    ----------
      30    signal_name : str
      31        Name of the signal for which anomaly scores are obtained
      32    initializers_strings : Collection[str]
      33        List of initializer names
      34    latent_spaces : Collection[int]
      35        List of latent space dimensions to compare
      36    bond_dim : dict
      37        Dictionary containing bond dimensions for each latent space - e.g. {'4': [2, 4], '8': [2, 4, 8]}
      38    embedding : str
      39        Embedding name for the model
      40    nruns : int
      41        Number of runs to average over
      42    save_dir : str
      43        Directory to save the plot
      44    train_scaling : str
      45        Training size and scaling to consider
      46
      47    Returns
      48    -------
      49    tpr_per_init : dict
      50        Dictionary containing true positive rates for each initializer and bond dimension
      51    tpr_per_init_err : dict
      52        Dictionary containing statistical errors for true positive rates - from multiple runs
      53    fpr_per_init : dict
      54        Dictionary containing false positive rates for each initializer and bond dimension
      55    fpr_per_init_err : dict
      56        Dictionary containing statistical errors for false positive rates - from multiple runs
      57    auc_per_init : dict
      58        Dictionary containing area under the curve values for each initializer and bond dimension
      59    auc_per_init_err : dict
      60        Dictionary containing statistical errors for area under the curve values - from multiple runs
      61    fpr_per_tpr_8_per_init : dict
      62        Dictionary containing false positive rates for TPR = 0.8 for each initializer and bond dimension
      63    fpr_per_tpr_8_per_init_err : dict
      64        Dictionary containing statistical errors for false positive rates for TPR = 0.8 - from multiple runs
      65    fpr_per_tpr_6_per_init : dict
      66        Dictionary containing false positive rates for TPR = 0.6 for each initializer and bond dimension
      67    fpr_per_tpr_6_per_init_err : dict
      68        Dictionary containing statistical errors for false positive rates for TPR = 0.6 - from multiple runs
      69    """
      70    # Initialize dictionaries to store results
      71    tpr_per_init = {}
      72    tpr_per_init_err = {}
      73    fpr_per_init = {}
      74    fpr_per_init_err = {}
      75    auc_per_init = {}
      76    auc_per_init_err = {}
      77    fpr_per_tpr_8_per_init = {}
      78    fpr_per_tpr_8_per_init_err = {}
      79    fpr_per_tpr_6_per_init = {}
      80    fpr_per_tpr_6_per_init_err = {}
      81
      82    for initializer in initializers_strings:
      83        for _i, lat in enumerate(latent_spaces):
      84            for _j, bond in enumerate(bond_dim[str(lat)]):
      85                loss_qcd_runs = []
      86                loss_sig_runs = []
      87                tpr_data = []
      88                fpr_data = []
      89                auc_data = []
      90                fpr_per_tpr_8_data = []
      91                fpr_per_tpr_6_data = []
      92                for run in range(1, nruns + 1):
      93                    path = f"{save_dir}/{initializer}/{train_scaling}/{embedding}/lat{lat}/bond{bond}/run_{run}/fidelity_scores_{signal_name}.h5"
      94                    if not os.path.exists(path):  # noqa: PTH110
      95                        continue
      96                    with h5py.File(path, "r") as file:
      97                        loss_qcd = file["loss_qcd"][:]
      98                        loss_sig = file["loss_sig"][:]
      99
     100                        loss_qcd = np.power(loss_qcd, 2)
     101                        loss_sig = np.power(loss_sig, 2)
     102
     103                        loss_qcd_runs.append(loss_qcd)
     104                        loss_sig_runs.append(loss_sig)
     105
     106                        fpr, tpr = get_roc_curve_data(
     107                            loss_sig, loss_qcd, anomaly_det=True
     108                        )
     109                        tpr_data.append(tpr)
     110                        fpr_data.append(fpr)
     111                        # Get auc
     112                        auc_value = auc(fpr, tpr)
     113                        auc_data.append(auc_value)
     114
     115                        # Get fpr per tpr = {0.8, 0.6}
     116                        fpr_per_tpr_8 = get_FPR_for_fixed_TPR(
     117                            0.8, np.array(fpr), np.array(tpr), tolerance=0.01
     118                        )
     119                        fpr_per_tpr_6 = get_FPR_for_fixed_TPR(
     120                            0.6, np.array(fpr), np.array(tpr), tolerance=0.01
     121                        )
     122                        fpr_per_tpr_8_data.append(fpr_per_tpr_8)
     123                        fpr_per_tpr_6_data.append(fpr_per_tpr_6)
     124
     125                loss_qcd = get_mean_and_error(np.array(loss_qcd_runs))
     126                loss_sig = get_mean_and_error(np.array(loss_sig_runs))
     127
     128                if np.isnan(loss_qcd[0]).sum() > 0:
     129                    print(f"{Colors.RED.value}{path}: NaNs{Colors.RESET.value}")
     130                    continue
     131
     132                # Get mean error for tpr, fpr
     133                tpr_mean_error = get_mean_and_error(np.array(tpr_data))
     134                tpr_per_init[
     135                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     136                ] = tpr_mean_error[0]
     137                tpr_per_init_err[
     138                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     139                ] = tpr_mean_error[1]
     140
     141                fpr_mean_error = get_mean_and_error(1.0 / np.array(fpr_data))
     142                fpr_per_init[
     143                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     144                ] = fpr_mean_error[0]
     145                fpr_per_init_err[
     146                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     147                ] = fpr_mean_error[1]
     148
     149                # AUC mean error
     150                auc_mean_error = get_mean_and_error(np.array(auc_data))
     151                auc_per_init[
     152                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     153                ] = auc_mean_error[0]
     154                auc_per_init_err[
     155                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     156                ] = auc_mean_error[1]
     157
     158                # fpr per tpr = 0.8
     159                fpr_per_tpr_8_mean_error = get_mean_and_error(
     160                    1.0 / np.array(fpr_per_tpr_8_data)
     161                )
     162                fpr_per_tpr_8_per_init[
     163                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     164                ] = fpr_per_tpr_8_mean_error[0]
     165                fpr_per_tpr_8_per_init_err[
     166                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     167                ] = fpr_per_tpr_8_mean_error[1]
     168
     169                # fpr per tpr = 0.6
     170                fpr_per_tpr_6_mean_error = get_mean_and_error(
     171                    1.0 / np.array(fpr_per_tpr_6_data)
     172                )
     173                fpr_per_tpr_6_per_init[
     174                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     175                ] = fpr_per_tpr_6_mean_error[0]
     176                fpr_per_tpr_6_per_init_err[
     177                    f"init={initializer},bond={bond},lat={lat},s={signal_name}"
     178                ] = fpr_per_tpr_6_mean_error[1]
     179
     180    return (
     181        tpr_per_init,
     182        tpr_per_init_err,
     183        fpr_per_init,
     184        fpr_per_init_err,
     185        auc_per_init,
     186        auc_per_init_err,
     187        fpr_per_tpr_8_per_init,
     188        fpr_per_tpr_8_per_init_err,
     189        fpr_per_tpr_6_per_init,
     190        fpr_per_tpr_6_per_init_err,
     191    )
     192
     193
     194def plot_losses_per_initializer(
     195    latent: int,
     196    bond_dims: Collection[int],
     197    initializers_strings: Collection[str],
     198    embedding: str,
     199    save_dir: str,
     200    N_epochs: int = 1000,
     201    train_size: str = "10k",
     202    minmax: str = "minmax-11",
     203    nruns: int = 5,
     204):
     205    """Create a subplot grid with training loss plots for all initializers for fixed embedding.
     206
     207    Parameters
     208    ----------
     209    latent : int
     210        Latent space dimension
     211    bond_dims : Collection[int]
     212        List of bond dimensions to compare
     213    initializers_strings : Collection[str]
     214        List of initializer names to compare
     215    embedding : str
     216        Embedding name for the model
     217    save_dir : str
     218        Directory to save the plot
     219    N_epochs : int, optional
     220        Number of epochs for training, default 1000
     221    train_size : str, optional
     222        Training size to consider, default "10k"
     223    minmax : str, optional
     224        Min-Max scaling option, default "minmax-11"
     225    nruns : int, optional
     226        Number of runs to average over, default 5
     227
     228    Returns
     229    -------
     230    None
     231
     232    Notes
     233    -----
     234    - The function creates a grid of subplots, each showing the training loss for a different initializer.
     235    - Each subplot contains multiple lines representing different bond dimensions - corresponding to different colors in a color palette.
     236    - The function is assuming the data files are structured in a specific way, but you can change it to fit your needs.
     237
     238
     239    Example
     240    -------
     241    >>> plot_losses_per_initializer(
     242    ...     latent = 4,
     243    ...     bond_dims = [2, 4, 8, 16],
     244    ...     initializers_strings = ['unitary_canonize', 'randn_std', 'randn_1e-2', 'gramschmidt_n_std'],
     245    ...     embedding = 'laguerre_2',
     246    ...     save_dir = './results',
     247    ...     N = 1000,
     248    ...     train_size = '10k',
     249    ...     minmax = 'minmax-11',
     250    ...     nruns = 10
     251    ... )
     252
     253    """
     254    # Calculate grid dimensions
     255    n_initializers = len(initializers_strings)
     256    n_cols = min(2, n_initializers)  # Maximum 4 columns
     257    n_rows = (n_initializers + n_cols - 1) // n_cols  # Ceiling division
     258
     259    # Color maps for different bond dimensions
     260    color_maps = [
     261        "Blues",
     262        "Reds",
     263        "Greens",
     264        "Purples",
     265        "Oranges",
     266        "YlOrBr",
     267        "GnBu",
     268        "PuRd",
     269    ]
     270
     271    # Create figure with subplots
     272    _fig, axes = plt.subplots(n_rows, n_cols, figsize=(6 * n_cols, 5 * n_rows))
     273
     274    # Flatten axes array for easy indexing if multiple rows/columns
     275    if n_rows > 1 or n_cols > 1:  # noqa: SIM108
     276        axes = axes.flatten()
     277    else:
     278        axes = [axes]  # Convert to list for single subplot case
     279
     280    # Plot for each initializer
     281    for i, initializer in enumerate(initializers_strings):
     282        ax = axes[i]
     283
     284        # Plot for each bond dimension
     285        for j, bond in enumerate(bond_dims):
     286            # Create color spectrum for this bond dimension
     287            cmap = plt.get_cmap(color_maps[j % len(color_maps)])
     288
     289            # Process each run
     290            for run in range(1, nruns + 1):
     291                # Load loss data
     292                file_path = f"{save_dir}/{initializer}/{train_size}_{minmax}/{embedding}/lat{latent}/bond{bond}/run_{run}/loss.npy"
     293
     294                try:
     295                    # Load and pad with NaN
     296                    loss_data = np.load(file_path)
     297                    loss = np.full(N_epochs, np.nan)
     298                    loss[: len(loss_data)] = loss_data
     299
     300                    # Plot with color from the spectrum
     301                    color_intensity = 0.3 + 0.6 * (run / nruns)
     302                    color = cmap(color_intensity)
     303
     304                    # Plot data
     305                    epochs = np.arange(1, N_epochs + 1)
     306                    ax.plot(epochs, loss, "-", color=color, linewidth=3, alpha=0.7)
     307
     308                except FileNotFoundError:
     309                    print(
     310                        f"{Colors.RED.value}File not found: {file_path}{Colors.RESET.value}"
     311                    )
     312                    continue
     313
     314            # Add label for this bond dimension (just once per bond)
     315            ax.plot([], [], "-", color=cmap(0.5), label=rf"$\chi$ = {bond}")
     316
     317        # Style this subplot
     318        ax.grid(True, linestyle="--", alpha=0.4)
     319        ax.set_xlabel("Epochs", fontsize=15)
     320        ax.set_ylabel("Training Loss", fontsize=15)
     321
     322        # Create a label for the initializer
     323        if initializer == "gramschmidt_n_std":
     324            initializer = "Gram-Schmidt (normal - std=0.1667)"
     325        elif initializer == "randn_std":
     326            initializer = "Random (normal - std=0.1667)"
     327        elif initializer == "randn_1e-2":
     328            initializer = "Random (normal - std=0.01)"
     329        elif initializer == "unitary":
     330            initializer = "Unitary"
     331
     332        ax.set_title(f"{initializer}", fontsize=17)
     333        ax.set_xlim(0, N_epochs)
     334
     335        # set font size for ticks
     336        ax.tick_params(axis="both", which="major", labelsize=12)
     337        ax.tick_params(axis="both", which="minor", labelsize=12)
     338
     339    axes[0].legend(loc="upper right", frameon=True, fontsize=14)
     340    # Hide any unused subplots
     341    for j in range(i + 1, n_rows * n_cols):
     342        if j < len(axes):
     343            axes[j].axis("off")
     344
     345    # Hide y-axis labels for all but the first column
     346    for ax in axes:
     347        ax.label_outer()
     348
     349    plt.tight_layout()
     350
     351    # Save figure
     352    os.makedirs(  # noqa: PTH103
     353        f"{save_dir}/plots/{train_size}_{minmax}/{embedding}/lat{latent}/",
     354        exist_ok=True,
     355    )
     356
     357    plt.savefig(
     358        f"{save_dir}/plots/{train_size}_{minmax}/{embedding}/lat{latent}/train_loss.pdf"
     359    )
     360
     361
     362def compare_anomaly_scores_per_embedding(
     363    embeddings: Collection[str],
     364    initializer: str,
     365    latent: int,
     366    bond_dims: Collection[int],
     367    signal_name: str,
     368    save_dir: str,
     369    train_size_scaling: Collection[str],
     370    nruns: int = 10,
     371):
     372    """
     373    Create a single plot with subplots for different embeddings, showing QCD and BSM
     374    anomaly scores distributions for each bond dimension with consistent coloring.
     375
     376    Parameters
     377    ----------
     378    embeddings : Collection[str]
     379        List of embedding names to compare
     380    initializer : str
     381        Initializer name for the model
     382    latent : int
     383        Latent space dimension
     384    bond_dims : Collection[int]
     385        List of bond dimensions to compare
     386    signal_name : str
     387        Name of the signal to be used in the plot
     388    save_dir : str
     389        Directory to save the plot
     390    train_size_scaling : Collection[str]
     391        List of training sizes and scaling to consider for each embedding
     392    nruns : int, optional
     393        Number of runs to average over, default 10
     394
     395    Returns
     396    -------
     397    fig : matplotlib.figure.Figure
     398        The figure object containing the plot
     399    axs : List[matplotlib.axes.Axes]
     400        List of axes objects for each subplot
     401
     402    Notes
     403    -----
     404    - The function assumes that the data files are structured in a specific way,
     405        with paths containing the bond dimension and run number.
     406    - The function uses a consistent color scheme for different bond dimensions across all subplots.
     407    - The function handles multiple runs by averaging the results and plotting the distributions.
     408
     409
     410    Example
     411    -------
     412    >>> compare_anomaly_scores_per_embedding(
     413    ...     embeddings = ['laguerre_2', 'legendre_2', 'hermite_2'],
     414    ...     initializer = 'unitary',
     415    ...     latent = 4,
     416    ...     bond_dims = [2, 4, 8, 16],
     417    ...     signal_name = 'AtoHZ_to_ZZZ_35',
     418    ...     save_dir = './results',
     419    ...     train_size_scaling = ['10k_minmax01', '10k_minmax-11', '10k_minmax-11'],
     420    ...     nruns = 10
     421    ... )
     422    """  # noqa: D205
     423    # Set up color maps for different bond dimensions - using brighter colors
     424    color_maps = [
     425        "Blues",
     426        "Reds",
     427        "Greens",
     428        "Purples",
     429        "Oranges",
     430        "YlOrBr",
     431        "GnBu",
     432        "PuRd",
     433    ]
     434
     435    # Create figure with subplots (one per embedding)
     436    fig, axs = plt.subplots(
     437        1, len(embeddings), figsize=(6 * len(embeddings), 5), sharey=True
     438    )
     439
     440    # Handle case of single embedding
     441    if len(embeddings) == 1:
     442        axs = [axs]
     443
     444    # Create a color dictionary with brighter colors (using 0.8 instead of 0.6)
     445    bond_colors = {
     446        bond: plt.get_cmap(color_maps[i % len(color_maps)])(0.8)
     447        for i, bond in enumerate(bond_dims)
     448    }
     449
     450    # Keep track of handles for the bond dimension legend
     451    bond_handles = []
     452
     453    # Process each embedding
     454    for e, embedding in enumerate(embeddings):
     455        train_scaling = list(train_size_scaling)[e]
     456        ax = axs[e]
     457
     458        # Process each bond dimension
     459        for bond in bond_dims:
     460            loss_qcd_runs = []
     461            loss_sig_runs = []
     462
     463            # Process multiple runs
     464            for run in range(1, nruns + 1):
     465                path = f"{save_dir}/{initializer}/{train_scaling}/{embedding}/lat{latent}/bond{bond}/run_{run}/fidelity_scores_{signal_name}.h5"
     466
     467                if not os.path.exists(path):  # noqa: PTH110
     468                    continue
     469
     470                # Load and process data
     471                with h5py.File(path, "r") as file:
     472                    loss_qcd = file["loss_qcd"][:]
     473                    loss_sig = file["loss_sig"][:]
     474
     475                    # Square the losses (as in original code)
     476                    loss_qcd = np.power(loss_qcd, 2)
     477                    loss_sig = np.power(loss_sig, 2)
     478
     479                    loss_qcd_runs.append(loss_qcd)
     480                    loss_sig_runs.append(loss_sig)
     481
     482            # Skip if no valid runs found
     483            if not loss_qcd_runs or not loss_sig_runs:
     484                continue
     485
     486            # Compute mean and error
     487            loss_qcd = get_mean_and_error(np.array(loss_qcd_runs))
     488            loss_sig = get_mean_and_error(np.array(loss_sig_runs))
     489
     490            # Skip if NaNs detected
     491            if np.isnan(loss_qcd[0]).sum() > 0:
     492                print(
     493                    f"{Colors.RED.value}NaNs detected for {embedding}, bond={bond}{Colors.RESET.value}"
     494                )
     495                continue
     496
     497            # Plot QCD distribution
     498            ax.hist(
     499                loss_qcd[0],
     500                bins=100,
     501                fill=False,
     502                color=bond_colors[bond],
     503                histtype="step",
     504                linewidth=2,
     505                alpha=1.0,
     506                density=True,
     507            )
     508
     509            # Plot BSM distribution
     510            ax.hist(
     511                loss_sig[0],
     512                bins=100,
     513                fill=True,
     514                color=bond_colors[bond],
     515                histtype="step",
     516                linewidth=2,
     517                alpha=0.4,
     518                linestyle="--",
     519                density=True,
     520            )
     521
     522            # Save handles for bond dimension legend (only once per bond dimension)
     523            if e == 0:
     524                bond_handles.append(
     525                    plt.Line2D(
     526                        [0],
     527                        [0],
     528                        color=bond_colors[bond],
     529                        linewidth=2,
     530                        label=f"χ = {bond}",
     531                    )
     532                )
     533
     534        # Style the subplot
     535        if embedding == "legendre_2":
     536            embedding_label = r"Legendre"
     537        elif embedding == "fourier_2":
     538            embedding_label = r"Fourier"
     539        elif embedding == "hermite_2":
     540            embedding_label = r"Hermite"
     541        elif embedding == "laguerre_2_old_2":
     542            embedding_label = r"Laguerre"
     543        else:
     544            embedding_label = embedding
     545
     546        ax.set_title(f"{embedding_label}", fontsize=25)
     547        ax.set_xlabel(r"Anomaly Score", fontsize=20)
     548        ax.set_yscale("log")
     549        ax.set_xlim(-0.01, 0.6)
     550
     551        # Only add y-label to first subplot
     552        if e == 0:
     553            ax.set_ylabel("Probability Density", fontsize=20)
     554
     555    # Create style legend handles that accurately represent the visualization
     556    style_handles = [
     557        mpatches.Patch(
     558            edgecolor="black", facecolor="none", linewidth=2, label="QCD (Background)"
     559        ),
     560        mpatches.Patch(facecolor="gray", alpha=0.4, label="BSM (Signal)"),
     561    ]
     562    # set tick size
     563    for ax in axs:
     564        ax.tick_params(axis="both", which="major", labelsize=15)
     565        ax.tick_params(axis="both", which="minor", labelsize=15)
     566
     567    # Add bond dimension legend at the figure level
     568    fig.legend(
     569        handles=bond_handles,
     570        loc="upper left",
     571        bbox_to_anchor=(0.05, 0.87),
     572        frameon=True,
     573        fontsize=10,
     574        title="Bond Dimension",
     575    )
     576
     577    # Add the QCD/BSM legend to the figure
     578    fig.legend(
     579        handles=style_handles,
     580        loc="upper right",
     581        bbox_to_anchor=(0.35, 0.87),
     582        frameon=True,
     583        fontsize=10,
     584        title="Distribution Type",
     585    )
     586
     587    plt.tight_layout(
     588        rect=(0, 0.05, 1, 0.95)
     589    )  # Adjust layout to make room for the legends
     590
     591    # Create save directory if it doesn't exist
     592    os.makedirs(f"{save_dir}/plots/comparisons/", exist_ok=True)  # noqa: PTH103
     593
     594    # Save figure
     595    plt.savefig(
     596        f"{save_dir}/plots/comparisons/embedding_comparison_{initializer}_lat{latent}_{signal_name}.pdf",
     597        bbox_inches="tight",
     598    )
     599
     600    return fig, axs
     601
     602
     603def compare_ROCs_per_bond(  # noqa: N802
     604    latent: int,
     605    bond_dim: Collection[int],
     606    initializer: str,
     607    initializer_string: str,  # noqa: ARG001
     608    tpr_per_init: dict,
     609    tpr_per_init_err: dict,  # noqa: ARG001
     610    fpr_per_init: dict,
     611    fpr_per_init_err: dict,
     612    auc_per_init: dict,
     613    auc_per_init_err: dict,
     614    save_dir: str,
     615    embedding: str,
     616    train_scaling: str,
     617    signal_name: str,
     618):
     619    """
     620    Create ROC curve plot for a single initializer with consistent coloring for bond dimensions.
     621
     622    Parameters
     623    ----------
     624    latent : int
     625        Latent space dimension
     626    bond_dim : Collection[int]
     627        List of bond dimensions to compare
     628    initializer : str
     629        Initializer of the model
     630    initializer_string : str
     631        String representation of the initializer
     632    tpr_per_init : dict
     633        Dictionary containing true positive rates for each bond dimension
     634    tpr_per_init_err : dict
     635        Dictionary containing statistical errors for true positive rates - from multiple runs
     636    fpr_per_init : dict
     637        Dictionary containing false positive rates for each bond dimension
     638    fpr_per_init_err : dict
     639        Dictionary containing statistical errors for false positive rates - from multiple runs
     640    auc_per_init : dict
     641        Dictionary containing area under the curve values for each bond dimension
     642    auc_per_init_err : dict
     643        Dictionary containing statistical errors for area under the curve values - from multiple runs
     644    save_dir : str
     645        Directory to save the plot
     646    embedding : str
     647        Embedding name for the model
     648    train_scaling : str
     649        Training size and scaling to consider
     650    signal_name : str
     651        Name of the signal to be used in the plot
     652
     653    Returns
     654    -------
     655    fig : matplotlib.figure.Figure
     656        The figure object containing the plot
     657    ax : matplotlib.axes.Axes
     658        The axes object for the plot
     659    """
     660    # Set up color maps for different bond dimensions - using brighter colors
     661    color_maps = [
     662        "Blues",
     663        "Reds",
     664        "Greens",
     665        "Purples",
     666        "Oranges",
     667        "YlOrBr",
     668        "GnBu",
     669        "PuRd",
     670    ]
     671
     672    # Create a color dictionary with brighter colors for consistent coloring
     673    bond_colors = {
     674        bond: plt.get_cmap(color_maps[i % len(color_maps)])(0.8)
     675        for i, bond in enumerate(bond_dim)
     676    }
     677
     678    # Create a single figure
     679    fig, ax = plt.subplots(figsize=(7, 7))
     680
     681    for _j, bond in enumerate(bond_dim):
     682        key = f"init={initializer},bond={bond},lat={latent},s={signal_name}"
     683
     684        if key not in tpr_per_init:
     685            print(f"{Colors.RED.value}{key} not found{Colors.RESET.value}")
     686            continue
     687
     688        tpr = tpr_per_init[key]
     689        # tpr_err = tpr_per_init_err[key]  # noqa: ERA001
     690        fpr = fpr_per_init[key]
     691        fpr_err = fpr_per_init_err[key]
     692        auc_value = auc_per_init[key]
     693        auc_err = auc_per_init_err[key]
     694
     695        if signal_name == "RSGraviton_WW_NA_35":  # uncertainties are bigger for G_NA
     696            band_ind = np.where(tpr > 0.6)[0]
     697        else:
     698            band_ind = np.where(tpr > 0.35)[0]
     699
     700        # Use consistent color based on bond dimension
     701        ax.plot(
     702            tpr,
     703            fpr,
     704            label=rf"$\chi$ = {bond} ({auc_value * 100.0:.2f})$\pm$({auc_err * 100.0:.2f})",
     705            linewidth=2,
     706            color=bond_colors[bond],
     707        )  # Use consistent color
     708
     709        # Error calculation in log space
     710        log_fpr = np.log10(fpr)
     711        rel_err = fpr_err / fpr  # Relative error
     712        log_err = (0.434) * rel_err  # Convert to log10 error (0.434 = 1/ln(10))
     713
     714        # Calculate bounds in log space, then convert back
     715        log_upper = log_fpr - log_err
     716        log_lower = log_fpr + log_err
     717        fpr_upper = 10**log_upper  # convert back to linear scale
     718        fpr_lower = 10**log_lower  # convert back to linear scale
     719
     720        # Add error bands with matching color
     721        ax.fill_between(
     722            tpr[band_ind],
     723            fpr_lower[band_ind],
     724            fpr_upper[band_ind],
     725            alpha=0.2,
     726            color=bond_colors[bond],
     727        )  # Match fill color
     728
     729    # Add vertical dotted lines at TPR = 0.6 and TPR = 0.8
     730    ax.axvline(x=0.6, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
     731    ax.axvline(x=0.8, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
     732
     733    # Style the plot
     734    # ax.set_title(f'Latent = {latent}, {initializer_string}', fontsize=16)  # noqa: ERA001
     735    ax.legend(loc="lower left", fontsize=12)
     736    ax.set_yscale("log")
     737    ax.set_xlabel("TPR", fontsize=20)
     738    ax.set_ylabel("FPR$^{-1}$", fontsize=20)
     739    ax.set_xticks(np.arange(0, 1.1, 0.2))
     740    ax.tick_params(axis="both", which="major", labelsize=14)
     741    ax.tick_params(axis="both", which="minor", labelsize=14)
     742    ax.grid(True, alpha=0.3)
     743
     744    plt.tight_layout()
     745
     746    # Create directory if it doesn't exist
     747    os.makedirs(  # noqa: PTH103
     748        f"{save_dir}/plots/{train_scaling}/{embedding}/lat{latent}", exist_ok=True
     749    )
     750
     751    # Save figure
     752    plt.savefig(
     753        f"{save_dir}/plots/{train_scaling}/{embedding}/lat{latent}/roc_curve_{signal_name}.pdf"
     754    )
     755
     756    return fig, ax
     757
     758
     759def compare_ROC_by_signal(  # noqa: N802
     760    signal_names: Collection[str],
     761    signal_labels: Collection[str],
     762    latent: int,
     763    bond_dim: Collection[int],
     764    initializer: str,
     765    initializer_string: str,  # noqa: ARG001
     766    tpr_per_init: dict,
     767    tpr_per_init_err: dict,  # noqa: ARG001
     768    fpr_per_init: dict,
     769    fpr_per_init_err: dict,
     770    auc_per_init: dict,
     771    auc_per_init_err: dict,
     772    save_dir: str,
     773    embedding: str,
     774    train_scaling: str,
     775):
     776    r"""
     777    Compare ROC curves for different signal types with fixed model parameters.
     778
     779    Parameters
     780    ----------
     781    signal_names : Collection[str]
     782        List of signal names to compare
     783    signal_labels : Collection[str]
     784        List of signal labels for the legend
     785    latent : int
     786        Latent space dimension
     787    bond_dim : Collection[int]
     788        List of bond dimensions to compare
     789    initializer : str
     790        Initializer of the model
     791    initializer_string : str
     792        String representation of the initializer
     793    tpr_per_init : dict
     794        Dictionary containing true positive rates for each signal
     795    tpr_per_init_err : dict
     796        Dictionary containing statistical errors for true positive rates - from multiple runs
     797    fpr_per_init : dict
     798        Dictionary containing false positive rates for each signal
     799    fpr_per_init_err : dict
     800        Dictionary containing statistical errors for false positive rates - from multiple runs
     801    auc_per_init : dict
     802        Dictionary containing area under the curve values for each signal
     803    auc_per_init_err : dict
     804        Dictionary containing statistical errors for area under the curve values - from multiple runs
     805    save_dir : str
     806        Directory to save the plot
     807    embedding : str
     808        Embedding name for the model
     809    train_scaling : str
     810        Training size and scaling to consider
     811
     812    Returns
     813    -------
     814    fig : matplotlib.figure.Figure
     815        The figure object containing the plot
     816    ax : matplotlib.axes.Axes
     817        The axes object for the plot
     818
     819    Example
     820    -------
     821    >>> compare_ROC_by_signal(
     822    ...     signal_names = ['RSGraviton_WW_NA_35', 'AtoHZ_to_ZZZ_35', 'RSGraviton_WW_BR_15'],
     823    ...     signal_labels = [r'Narrow $G \rightarrow WW$', r'$A \rightarrow HZ \rightarrow ZZZ$', r'Broad $G \rightarrow WW$'],
     824    ...     latent = 4,
     825    ...     bond_dim = [2, 4, 8, 16],
     826    ...     initializer = 'unitary',
     827    ...     initializer_string = 'Unitary',
     828    ...     tpr_per_init = tpr_per_init,
     829    ...     tpr_per_init_err = tpr_per_init_err,
     830    ...     fpr_per_init = fpr_per_init,
     831    ...     fpr_per_init_err = fpr_per_init_err,
     832    ...     auc_per_init = auc_per_init,
     833    ...     auc_per_init_err = auc_per_init_err,
     834    ...     save_dir = './results',
     835    ...     embedding = 'laguerre_2',
     836    ...     train_scaling = '10k_minmax-11'
     837    ... )
     838
     839    """
     840    palette = ["#4CA64C", "#FF5733", "#8A2BE2"]
     841
     842    fig, ax = plt.subplots(figsize=(7, 7))
     843
     844    # Store handles and data for legend
     845    handles = []
     846    auc_info = []
     847    signal_info = []
     848
     849    for i, signal_name in enumerate(signal_names):
     850        key = f"init={initializer},bond={bond_dim},lat={latent},s={signal_name}"
     851
     852        if key not in tpr_per_init:
     853            print(f"{Colors.RED.value}{key} not found{Colors.RESET.value}")
     854            continue
     855
     856        tpr = tpr_per_init[key]
     857        fpr = fpr_per_init[key]
     858        fpr_err = fpr_per_init_err[key]
     859        auc_value = auc_per_init[key]
     860        auc_err = auc_per_init_err[key]
     861
     862        # Determine where to show error bands
     863        band_ind = np.where(tpr > 0.35)[0]
     864        if "RSGraviton_WW_NA" in signal_name:
     865            band_ind = np.where(tpr > 0.6)[0]
     866
     867        # Plot the ROC curve
     868        (line,) = ax.plot(tpr, fpr, linewidth=2, color=palette[i])
     869
     870        # Store data for legend
     871        handles.append(line)
     872        auc_info.append(f"{auc_value * 100:.2f}±{auc_err * 100:.2f}")
     873        signal_info.append(list(signal_labels)[i])
     874
     875        # Error calculation and bands
     876        log_fpr = np.log10(fpr)
     877        rel_err = fpr_err / fpr
     878        log_err = 0.434 * rel_err
     879
     880        log_upper = log_fpr - log_err
     881        log_lower = log_fpr + log_err
     882        fpr_upper = 10**log_upper
     883        fpr_lower = 10**log_lower
     884
     885        ax.fill_between(
     886            tpr[band_ind],
     887            fpr_lower[band_ind],
     888            fpr_upper[band_ind],
     889            alpha=0.2,
     890            color=palette[i],
     891        )
     892
     893    # Create legend with AUC values first
     894    legend_labels = []
     895    for auc_label, signal in zip(auc_info, signal_info, strict=False):
     896        legend_labels.append(f"{auc_label}   {signal}")
     897
     898    # Add legend with AUC first
     899    legend = ax.legend(
     900        handles, legend_labels, loc="lower left", frameon=True, fontsize=12
     901    )
     902
     903    # Add the column headers with AUC first
     904    legend.set_title("    AUC               BSM Scenario", prop=FontProperties(size=12))
     905
     906    # Add vertical dotted lines at TPR = 0.6 and TPR = 0.8
     907    ax.axvline(x=0.6, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
     908    ax.axvline(x=0.8, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
     909
     910    # Style the plot
     911    ax.set_yscale("log")
     912    ax.set_xlabel("TPR", fontsize=20)
     913    ax.set_ylabel("FPR$^{-1}$", fontsize=20)
     914    ax.set_xticks(np.arange(0, 1.1, 0.2))
     915    ax.tick_params(axis="both", which="major", labelsize=14)
     916    ax.tick_params(axis="both", which="minor", labelsize=14)
     917
     918    # ax.set_yticks(fontsize=14)  # noqa: ERA001
     919    ax.grid(True, alpha=0.3)
     920
     921    plt.tight_layout()
     922
     923    # Create directory and save
     924    os.makedirs(  # noqa: PTH103
     925        f"{save_dir}/plots/{train_scaling}/{embedding}/lat{latent}", exist_ok=True
     926    )
     927    plt.savefig(
     928        f"{save_dir}/plots/{train_scaling}/{embedding}/lat{latent}/roc_curve_compare_signals.pdf"
     929    )
     930
     931    return fig, ax
     932
     933
     934def compare_ROC_by_latent(  # noqa: N802
     935    latent_spaces: Collection[int],
     936    bond_dims: dict,
     937    initializer: str,
     938    initializer_string: str,  # noqa: ARG001
     939    signal_name: str,
     940    signal_label: str,  # noqa: ARG001
     941    tpr_per_init: dict,
     942    tpr_per_init_err: dict,  # noqa: ARG001
     943    fpr_per_init: dict,
     944    fpr_per_init_err: dict,
     945    auc_per_init: dict,
     946    auc_per_init_err: dict,
     947    save_dir: str,
     948    embedding: str,
     949    train_scaling: str,
     950):
     951    """
     952    Compare ROC curves for one signal type across different latent spaces, each with a specific bond dimension.
     953
     954    Parameters
     955    ----------
     956    latent_spaces : Collection[int]
     957        List of latent space dimensions to compare
     958    bond_dims : dict
     959        Dictionary mapping latent space dimensions to bond dimensions
     960    initializer : str
     961        Initializer of the model
     962    initializer_string : str
     963        String representation of the initializer
     964    signal_name : str
     965        Name of the signal anomaly scores are calculated for
     966    signal_label : str
     967        Label for the signal to be used in the plot
     968    tpr_per_init : dict
     969        Dictionary containing true positive rates for each latent space
     970    tpr_per_init_err : dict
     971        Dictionary containing statistical errors for true positive rates - from multiple runs
     972    fpr_per_init : dict
     973        Dictionary containing false positive rates for each latent space
     974    fpr_per_init_err : dict
     975        Dictionary containing statistical errors for false positive rates - from multiple runs
     976    auc_per_init : dict
     977        Dictionary containing area under the curve values for each latent space
     978    auc_per_init_err : dict
     979        Dictionary containing statistical errors for area under the curve values - from multiple runs
     980    save_dir : str
     981        Directory to save the plot
     982    embedding : str
     983        Embedding name for the model
     984    train_scaling : str
     985        Training size and scaling used in training
     986
     987    """
     988    # Colors for different latent spaces
     989    palette = [
     990        "#E69F00",  # Muted orange
     991        "#CC6677",  # Muted red
     992        "#88CCEE",  # Muted blue
     993        "#000000",  # Black
     994        "#44AA99",  # Muted teal
     995        "#AA4499",
     996    ]  # Muted purple
     997
     998    if len(latent_spaces) > len(palette):
     999        # Generate more colors if needed
    1000        cmap = plt.get_cmap("tab10")
    1001        palette = [cmap(i) for i in np.linspace(0, 1, len(latent_spaces))]
    1002
    1003    fig, ax = plt.subplots(figsize=(7, 7))
    1004
    1005    # Store handles and data for legend
    1006    handles = []
    1007    auc_info = []
    1008    config_info = []
    1009
    1010    for i, latent in enumerate(latent_spaces):
    1011        # Get corresponding bond dimension for this latent space
    1012        bond_dim = bond_dims[str(latent)]
    1013
    1014        key = f"init={initializer},bond={bond_dim},lat={latent},s={signal_name}"
    1015
    1016        if key not in tpr_per_init:
    1017            print(f"{key} not found")
    1018            continue
    1019
    1020        tpr = tpr_per_init[key]
    1021        fpr = fpr_per_init[key]
    1022        fpr_err = fpr_per_init_err[key]
    1023        auc_value = auc_per_init[key]
    1024        auc_err = auc_per_init_err[key]
    1025
    1026        if auc_value < 0.5:
    1027            auc_value = 1 - auc_value
    1028
    1029        # Determine where to show error bands
    1030        band_ind = np.where(tpr > 0.35)[0]
    1031        if "RSGraviton_WW_NA" in signal_name:
    1032            band_ind = np.where(tpr > 0.6)[0]
    1033
    1034        # Plot the ROC curve
    1035        (line,) = ax.plot(tpr, fpr, linewidth=2, color=palette[i % len(palette)])
    1036
    1037        # Store data for legend
    1038        handles.append(line)
    1039        auc_info.append(f"{auc_value * 100:.2f}±{auc_err * 100:.2f}")
    1040        config_info.append(f"lat = {latent}, χ = {bond_dim}")
    1041
    1042        # Error calculation and bands
    1043        log_fpr = np.log10(fpr)
    1044        rel_err = fpr_err / fpr
    1045        log_err = 0.434 * rel_err
    1046
    1047        log_upper = log_fpr - log_err
    1048        log_lower = log_fpr + log_err
    1049        fpr_upper = 10**log_upper
    1050        fpr_lower = 10**log_lower
    1051
    1052        ax.fill_between(
    1053            tpr[band_ind],
    1054            fpr_lower[band_ind],
    1055            fpr_upper[band_ind],
    1056            alpha=0.2,
    1057            color=palette[i % len(palette)],
    1058        )
    1059
    1060    # Create legend with AUC values first
    1061    legend_labels = []
    1062    for auc_label, config in zip(auc_info, config_info, strict=False):
    1063        legend_labels.append(f"{auc_label}   {config}")
    1064
    1065    # Add legend with AUC first
    1066    legend = ax.legend(
    1067        handles, legend_labels, loc="lower left", frameon=True, fontsize=12
    1068    )
    1069
    1070    # Add the column headers with AUC first
    1071    legend.set_title(
    1072        "    AUC               Configuration", prop=FontProperties(size=12)
    1073    )
    1074
    1075    # Add vertical dotted lines at TPR = 0.6 and TPR = 0.8
    1076    ax.axvline(x=0.6, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
    1077    ax.axvline(x=0.8, color="black", linestyle=":", linewidth=1.5, alpha=0.3)
    1078
    1079    # Style the plot
    1080    ax.set_yscale("log")
    1081    ax.set_xlabel("TPR", fontsize=20)
    1082    ax.set_ylabel("FPR$^{-1}$", fontsize=20)
    1083    # ax.set_title(f'Signal: {signal_label}, {initializer_string}', fontsize=16)  # noqa: ERA001
    1084    ax.set_xticks(np.arange(0, 1.1, 0.2))
    1085    ax.tick_params(axis="both", which="major", labelsize=14)
    1086    ax.tick_params(axis="both", which="minor", labelsize=14)
    1087    ax.grid(True, alpha=0.3)
    1088
    1089    plt.tight_layout()
    1090
    1091    # Create directory and save
    1092    os.makedirs(f"{save_dir}/plots/{train_scaling}/{embedding}", exist_ok=True)  # noqa: PTH103
    1093    plt.savefig(
    1094        f"{save_dir}/plots/{train_scaling}/{embedding}/roc_curve_latent_comparison_{signal_name}.pdf"
    1095    )
    1096
    1097    return fig, ax
    
  • utils.py: * View in browser: utils.py * View on GitHub

    utils.py#
      1import os
      2import tarfile
      3from enum import Enum
      4from pathlib import Path
      5
      6import h5py
      7import jax
      8import joblib
      9import numpy as np
     10import wget
     11from sklearn.preprocessing import MinMaxScaler, StandardScaler
     12
     13from tn4ml.embeddings import Embedding, TrigonometricEmbedding, embed
     14
     15
     16class Colors(Enum):
     17    """ANSI color codes for terminal text styling."""
     18
     19    RESET = "\033[0m"
     20    GREEN = "\033[32m"
     21    BLUE = "\033[34m"
     22    ORANGE = "\033[38;2;255;165;0m"
     23    PINK = "\033[38;2;255;105;180m"
     24    RED = "\033[31m"
     25    YELLOW = "\033[33m"
     26    MAGENTA = "\033[35m"
     27    CYAN = "\033[36m"
     28    BOLD = "\033[1m"
     29    UNDERLINE = "\033[4m"
     30
     31
     32def _download_data(data_url: str, data_dir: str | Path = "."):
     33    """
     34    Downloads the jet data if it does not already exist.
     35
     36    Parameters
     37    ----------
     38    data_url : str
     39        URL to the data file
     40    data_dir : str, optional
     41        Directory to save the data, default '.'
     42
     43    Returns
     44    -------
     45    None
     46    """  # noqa: D401
     47    data_path = Path(data_dir)
     48    if not data_path.is_dir():
     49        os.makedirs(data_path, exist_ok=True)  # noqa: PTH103
     50
     51    data_file_path = wget.download(data_url, out=str(data_path))
     52
     53    data_tar = tarfile.open(data_file_path, "r:gz")  # noqa: SIM115
     54    data_tar.extractall(str(data_path))
     55    data_tar.close()
     56    os.remove(data_file_path)  # noqa: PTH107
     57
     58
     59def _ensure_data_exists(data_dir: str = "data", latent: int | None = None) -> None:
     60    """
     61    Check if the data directory exists and download all data if it doesn't.
     62    Only checks directory existence, not individual files.
     63
     64    Parameters
     65    ----------
     66    data_dir : str, optional
     67        Base directory where data should be stored, default "data"
     68    latent : int, optional
     69        Latent space dimension used for subdirectory, default None
     70
     71    Returns
     72    -------
     73    dict
     74        Dictionary with paths to all data files
     75    """  # noqa: D205
     76    # Create data directory path with latent dimension subfolder
     77    base_dir = Path(data_dir)
     78    data_dir_path = base_dir / f"latent{latent}" if latent is not None else base_dir
     79
     80    # ONLY check if the directory exists, not individual files
     81    if not data_dir_path.is_dir() or not any(data_dir_path.iterdir()):
     82        print(
     83            f"{Colors.BLUE.value}Data directory {data_dir_path} does not exist. Downloading complete dataset...{Colors.RESET.value}"
     84            + "\n"
     85        )
     86        os.makedirs(data_dir_path, exist_ok=True)  # noqa: PTH103
     87
     88        archive_url = "https://zenodo.org/records/7673769/files/QML_paper_data.tar.gz"
     89        try:
     90            _download_data(archive_url, base_dir)  # Download to base dir
     91            print(
     92                f"{Colors.BLUE.value}Archive downloaded and extracted successfully.{Colors.RESET.value}"
     93                + "\n"
     94            )
     95        except Exception as e:  # noqa: BLE001
     96            print(
     97                f"{Colors.RED.value}Failed to download archive: {e}{Colors.RESET.value}"
     98                + "\n"
     99            )
    100    else:
    101        print(
    102            f"{Colors.YELLOW.value}Data directory {data_dir_path} already exists. Assuming all data is present.{Colors.RESET.value}"
    103            + "\n"
    104        )
    105
    106    return
    107
    108
    109def load_train_data(
    110    read_file: str,
    111    train_size: int = 10000,
    112    apply_minmax: bool = False,
    113    apply_standardization: bool = False,
    114    feature_range: tuple = (0, 1),
    115    shuffle_seed: int = 42,
    116    save_dir: str = ".",
    117    prefix: str = "train_qcd",
    118):
    119    """
    120    Load and preprocess training data from a given file.
    121
    122    Parameters
    123    ----------
    124    read_file : str
    125        Path to the file containing the training data
    126    train_size : int, optional
    127        Number of training samples to load, default 10000
    128    apply_minmax : bool, optional
    129        Whether to apply Min-Max scaling, default False
    130    apply_standardization : bool, optional
    131        Whether to apply standardization, default False
    132    feature_range : tuple, optional
    133        The desired range for Min-Max scaling, default (0, 1)
    134    shuffle_seed : int, optional
    135        Seed for random shuffling, default 42
    136    save_dir : str, optional
    137        Directory to save scalers, default '.'
    138    prefix : str, optional
    139        Prefix for scaler filenames, default 'train_qcd'
    140
    141    Returns
    142    -------
    143    np.ndarray
    144        Preprocessed training data
    145    dict
    146        Dictionary of fitted scalers
    147    """
    148    # Ensure the save directory exists
    149    os.makedirs(save_dir, exist_ok=True)  # noqa: PTH103
    150
    151    # Read and prepare data
    152    with h5py.File(read_file, "r") as file:
    153        data = file["latent_space"]
    154        data = np.concatenate([data[:, 0, :], data[:, 1, :]], axis=-1)
    155        print(
    156            f"{Colors.BLUE.value}Input data shape: {data.shape}{Colors.RESET.value}"
    157            + "\n"
    158        )
    159
    160        # Shuffle data
    161        np.random.seed(shuffle_seed)  # noqa: NPY002
    162        np.random.shuffle(data)  # noqa: NPY002
    163
    164    data_train = data[:train_size]
    165    scalers = {}
    166
    167    # Apply transformations
    168    if apply_standardization:
    169        scaler = StandardScaler()
    170        data_train = scaler.fit_transform(data_train)
    171        scalers["standard"] = scaler
    172        scaler_path = os.path.join(save_dir, f"scaler_standard_{prefix}.pkl")  # noqa: PTH118
    173        joblib.dump(scaler, scaler_path)
    174
    175    if apply_minmax:
    176        min_max_scaler = MinMaxScaler(feature_range=feature_range)
    177        data_train = min_max_scaler.fit_transform(data_train)
    178        scalers["minmax"] = min_max_scaler
    179        scaler_path = os.path.join(save_dir, f"scaler_minmax_{prefix}.pkl")  # noqa: PTH118
    180        joblib.dump(min_max_scaler, scaler_path)
    181
    182    return data_train, scalers
    183
    184
    185def load_test_data(
    186    read_path: str,
    187    dataset_type: str = "qcd",
    188    scaler: StandardScaler = None,
    189    min_max_scaler: MinMaxScaler = None,
    190    test_size: int = 10000,
    191    shuffle_seed: int = 42,
    192):
    193    """
    194    Load and preprocess test data from a given file.
    195
    196    Parameters
    197    ----------
    198    read_path : str
    199        Path to the file/directory containing test data
    200    dataset_type : str, optional
    201        Type of dataset ('qcd' or 'signal'), default 'qcd'
    202    scaler : StandardScaler, optional
    203        Fitted StandardScaler to apply, default None
    204    min_max_scaler : MinMaxScaler, optional
    205        Fitted MinMaxScaler to apply, default None
    206    test_size : int, optional
    207        Number of test samples to use, default 10000
    208    shuffle_seed : int, optional
    209        Seed for random shuffling, default 42
    210
    211    Returns
    212    -------
    213    np.ndarray
    214        Preprocessed test data
    215    """
    216    # Determine file path based on dataset type
    217    if dataset_type == "qcd":
    218        file_path = os.path.join(read_path, "latentrep_QCD_sig_testclustering.h5")  # noqa: PTH118
    219    else:
    220        file_path = read_path  # For signal, use the path directly
    221
    222    # Load and prepare data
    223    with h5py.File(file_path, "r") as file:
    224        data = file["latent_space"]
    225        # Concatenate the two latent space components
    226        data_test = np.concatenate([data[:, 0, :], data[:, 1, :]], axis=-1)
    227
    228        # Shuffle data
    229        np.random.seed(shuffle_seed)  # noqa: NPY002
    230        np.random.shuffle(data_test)  # noqa: NPY002
    231        data_test = data_test[:test_size]
    232
    233    print(
    234        f"{Colors.BLUE.value}Input test {dataset_type} shape: {data_test.shape}{Colors.RESET.value}"
    235        + "\n"
    236    )
    237
    238    # Apply transformations if provided
    239    if scaler is not None:
    240        data_test = scaler.transform(data_test)
    241    if min_max_scaler is not None:
    242        data_test = min_max_scaler.transform(data_test)
    243
    244    return data_test
    245
    246
    247def calc_fidelity_batch(
    248    points,
    249    model,
    250    embedding: Embedding = TrigonometricEmbedding(),  # noqa: B008
    251    batch_size: int = 1000,
    252):
    253    """
    254    Calculate fidelity scores for data points in batches using vectorized operations.
    255
    256    Parameters
    257    ----------
    258    points : np.ndarray
    259        Input data points
    260    model : TensorNetwork
    261        Trained tensor network model
    262    embedding : Embedding, optional
    263        Embedding function, default TrigonometricEmbedding()
    264    batch_size : int, optional
    265        Batch size for processing, default 1000
    266
    267    Returns
    268    -------
    269    np.ndarray
    270        Array of fidelity scores
    271    """
    272
    273    # Define a function that processes a single point
    274    def single_point_fidelity(point):
    275        input_mps = embed(point, embedding)
    276        p_mps = input_mps.H & model
    277        return abs(p_mps ^ all)
    278
    279    # Vectorize the function for batch processing
    280    batch_fidelity = jax.vmap(single_point_fidelity)
    281
    282    # Process in batches to avoid memory issues
    283    n_samples = len(points)
    284    n_batches = (n_samples + batch_size - 1) // batch_size
    285    results = []
    286
    287    for i in range(n_batches):
    288        start_idx = i * batch_size
    289        end_idx = min(start_idx + batch_size, n_samples)
    290        batch = points[start_idx:end_idx]
    291        batch_results = batch_fidelity(batch)
    292        results.append(batch_results)
    293
    294    return np.concatenate(results, axis=0)