Code: import sys
import os
import glob
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from PyQt6.QtWidgets import (
    QApplication,
    QWidget,
    QLabel,
    QSlider,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QHBoxLayout,
    QMessageBox,
    QFileDialog,
    QComboBox,
    QGroupBox,
    QProgressBar,
    QButtonGroup,
    QRadioButton,
    QListWidget,
    QScrollArea,
    QSizePolicy,
)
from PyQt6.QtGui import (
    QFont,
    QPalette,
    QColor,
    QDoubleValidator,
    QIntValidator,
)
from PyQt6.QtCore import Qt, QEvent
import joblib
import json

# Define architecture mapping here for consistency
ARCHITECTURE_MAPPING = {
    "PASCAL": 0,
    "TURING": 1,
    "AMPERE": 2,
    "ADA": 3,
    "GCN5": 4,
    "RDNA": 5,
    "RDNA2": 6,
    "RDNA3": 7,
}

RESOLUTION_MAPPING = {
    "0": 0,  # For foundation model
    "1080p": 1,
    "1440p": 2,
    "4K": 3,
}

NUMERIC_FEATURES = [
    "Num Shaders",
    "Clock Speed (GHz)",
    "ROPs",
    "TMUs",
    "L2 Cache (MB)",
    "VRAM (GB)",
    "Bandwidth (GB/s)",
    "TFlops",
]

DEFAULT_MODEL_PATH = "models/Foundation_Model.pth"
DEFAULT_SCALER_PATH = "models/Foundation_Model_scaler.pkl"
GPU_DATA_PATH = "gpu_data.csv"

# --- Model Architecture ---
class GPUPerformanceModel(nn.Module):
    def __init__(self, input_size, num_architectures, num_resolutions):
        super(GPUPerformanceModel, self).__init__()
        embedding_dim_arch = 4
        embedding_dim_res = 2
        self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=embedding_dim_arch)
        self.res_embedding = nn.Embedding(num_resolutions, embedding_dim=embedding_dim_res)
        total_embedding_dim = embedding_dim_arch + embedding_dim_res

        self.model = nn.Sequential(
            nn.Linear(input_size + total_embedding_dim, 128),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(64, 1)
        )

    def forward(self, x_num, x_cat):
        x_arch = self.arch_embedding(x_cat[:, 0])
        x_res = self.res_embedding(x_cat[:, 1])
        x_cat = torch.cat([x_arch, x_res], dim=1)
        x = torch.cat([x_num, x_cat], dim=1)
        return self.model(x)

class PredictionApp(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("GPU Performance Predictor")
        # Remove fixed size to allow resizing
        # self.setFixedSize(900, 900)
        # Optionally, set a minimum size
        self.setMinimumSize(800, 600)

        self.model = None
        self.scaler = None
        self.model_config = None
        self.target_performance = 1.0

        self.main_df = pd.read_csv(GPU_DATA_PATH)
        # Assume GPU model names match and are correct
        # Keep 'Architecture' and 'Resolution' as is

        # --- Input Fields with Validation ---
        self.create_input_fields()

        # --- Architecture Dropdown ---
        self.architecture_label = QLabel("Architecture:")
        self.architecture_combo = QComboBox()
        self.architecture_combo.addItems(list(ARCHITECTURE_MAPPING.keys()))
        self.architecture_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

        # --- Resolution Dropdown ---
        self.resolution_label = QLabel("Resolution:")
        self.resolution_combo = QComboBox()
        self.resolution_combo.addItems(['1080p', '1440p', '4K'])
        # For foundation model, 'Resolution' should be set to '0'
        self.resolution_combo.setVisible(False)
        self.resolution_label.setVisible(False)

        # --- Buttons ---
        self.load_model_button = QPushButton("Load Model")
        self.load_model_button.clicked.connect(self.load_model)

        self.load_scaler_button = QPushButton("Load Scaler")
        self.load_scaler_button.clicked.connect(self.load_scaler)

        self.batch_predict_button = QPushButton("Batch Predict")
        self.batch_predict_button.clicked.connect(self.batch_predict)

        self.predict_button = QPushButton("Predict Performance")
        self.predict_button.clicked.connect(self.predict_performance)
        self.predict_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

        # --- Mode Selection Radio Buttons ---
        self.mode_group = QButtonGroup(self)
        self.foundation_mode_radio = QRadioButton("Foundation Model", self)
        self.game_mode_radio = QRadioButton("Game-Specific Model", self)
        self.mode_group.addButton(self.foundation_mode_radio)
        self.mode_group.addButton(self.game_mode_radio)
        self.foundation_mode_radio.setChecked(True)
        self.foundation_mode_radio.toggled.connect(self.update_mode)
        self.game_mode_radio.toggled.connect(self.update_mode)

        # --- Game Selection ---
        self.game_label = QLabel("Select Game:")
        self.game_combo = QComboBox()
        self.game_combo.addItem("Select Game")
        self.load_game_models()
        self.game_label.setVisible(False)
        self.game_combo.setVisible(False)
        self.game_combo.currentIndexChanged.connect(self.game_selection_changed)
        self.game_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)

        # --- Layout ---
        main_layout = QHBoxLayout()

        # --- Left Column Layout ---
        left_column_layout = QVBoxLayout()

        # Model Loading Group
        model_group = QGroupBox("Model Loading")
        model_layout = QVBoxLayout()
        model_layout.addWidget(self.load_model_button)
        model_layout.addWidget(self.load_scaler_button)
        model_layout.addWidget(self.batch_predict_button)
        model_layout.addStretch(1)
        model_group.setLayout(model_layout)
        left_column_layout.addWidget(model_group)

        # Prediction Mode
        mode_group = QGroupBox("Prediction Mode")
        mode_layout = QVBoxLayout()
        mode_layout.addWidget(self.foundation_mode_radio)
        mode_layout.addWidget(self.game_mode_radio)
        mode_layout.addWidget(self.game_label)
        mode_layout.addWidget(self.game_combo)
        mode_group.setLayout(mode_layout)
        left_column_layout.addWidget(mode_group)

        left_column_layout.addStretch(1)

        # --- Right Column Layout ---
        right_column_layout = QVBoxLayout()

        # Input Fields Group
        input_group = QGroupBox("GPU Specifications")
        input_layout = QVBoxLayout()

        # Performance Characteristics Group
        perf_group = QGroupBox("Performance Characteristics")
        perf_layout = QVBoxLayout()
        perf_layout.addWidget(self.num_shaders_edit)
        perf_layout.addWidget(self.clock_speed_edit)
        perf_layout.addWidget(self.rops_edit)
        perf_layout.addWidget(self.tmus_edit)
        perf_layout.addWidget(self.tflops_edit)
        perf_group.setLayout(perf_layout)
        input_layout.addWidget(perf_group)

        # Memory Characteristics Group
        memory_group = QGroupBox("Memory Characteristics")
        memory_layout = QVBoxLayout()
        memory_layout.addWidget(self.l2_cache_edit)
        memory_layout.addWidget(self.vram_edit)
        memory_layout.addWidget(self.bandwidth_edit)
        memory_group.setLayout(memory_layout)
        input_layout.addWidget(memory_group)

        input_layout.addWidget(self.architecture_label)
        input_layout.addWidget(self.architecture_combo)
        input_layout.addWidget(self.resolution_label)
        input_layout.addWidget(self.resolution_combo)
        input_group.setLayout(input_layout)
        right_column_layout.addWidget(input_group)

        right_column_layout.addWidget(self.predict_button)

        # --- Output ---
        self.output_label = QLabel("Predicted Performance:")
        self.output_label.setFont(QFont("Arial", 14))
        self.output_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        right_column_layout.addWidget(self.output_label)

        self.performance_bar = QProgressBar(self)
        # self.performance_bar.setGeometry(50, 450, 450, 20)
        self.performance_bar.setTextVisible(False)
        right_column_layout.addWidget(self.performance_bar)

        right_column_layout.addStretch(1)

        # --- Combine Columns ---
        main_layout.addLayout(left_column_layout)
        main_layout.addLayout(right_column_layout)
        main_layout.setStretch(0, 1)
        main_layout.setStretch(1, 2)

        self.setLayout(main_layout)
        self.apply_dark_mode()

        # --- Load Default Model ---
        self.load_default_model()

        # --- Initially set the mode ---
        self.update_mode()

    def create_input_fields(self):
        self.num_shaders_edit = self.create_validated_field(
            "Num Shaders:", validator=QIntValidator(0, 100000)
        )
        self.clock_speed_edit = self.create_validated_field(
            "Clock Speed (GHz):", validator=QDoubleValidator(0.0, 5.0, 4)
        )
        self.rops_edit = self.create_validated_field(
            "ROPs:", validator=QIntValidator(0, 256)
        )
        self.tmus_edit = self.create_validated_field(
            "TMUs:", validator=QIntValidator(0, 640)
        )
        self.l2_cache_edit = self.create_validated_field(
            "L2 Cache (MB):", validator=QDoubleValidator(0.0, 128.0, 3)
        )
        self.vram_edit = self.create_validated_field(
            "VRAM (GB):", validator=QDoubleValidator(0.0, 128.0, 2)
        )
        self.bandwidth_edit = self.create_validated_field(
            "Bandwidth (GB/s):", validator=QDoubleValidator(0.0, 2000.0, 2)
        )
        self.tflops_edit = self.create_validated_field(
            "TFlops:", validator=QDoubleValidator(0.0, 100.0, 4)
        )

    def create_validated_field(self, label_text, validator):
        layout = QVBoxLayout()

        label = QLabel(label_text)
        label.setToolTip(self.get_tooltip(label_text))
        layout.addWidget(label)

        edit = QLineEdit()
        edit.setValidator(validator)
        edit.textChanged.connect(lambda: self.validate_input(edit))
        edit.installEventFilter(self)
        edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        layout.addWidget(edit)

        widget = QWidget()
        widget.setLayout(layout)
        widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        return widget

    def get_tooltip(self, field_name):
        tooltips = {
            "Num Shaders:": "The number of shader cores in the GPU.",
            "Clock Speed (GHz):": "The clock speed of the GPU's core, in GHz.",
            "ROPs:": "Render Output Units: Responsible for writing pixels to memory.",
            "TMUs:": "Texture Mapping Units: Handle texture operations.",
            "TFlops:": "Teraflops: A measure of theoretical computing performance.",
            "L2 Cache (MB):": "The size of the GPU's L2 cache, in MB.",
            "VRAM (GB):": "The amount of video memory on the GPU, in GB.",
            "Bandwidth (GB/s):": "The speed of data transfer between the GPU and memory, in GB/s."
        }
        return tooltips.get(field_name, "")

    def eventFilter(self, source, event):
        if event.type() == QEvent.Type.KeyPress:
            if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
                self.focusNextChild()
                return True
        return super().eventFilter(source, event)

    def apply_dark_mode(self):
        palette = QPalette()
        palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
        palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
        palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
        self.setPalette(palette)

    def load_game_models(self):
        # Load available game models from the models folder
        self.game_models = []
        model_files = glob.glob("models/*.pth")
        for model_file in model_files:
            model_name = os.path.splitext(os.path.basename(model_file))[0]
            if model_name != "Foundation_Model":
                self.game_combo.addItem(model_name)
                self.game_models.append(model_name)

    def update_mode(self):
        if self.foundation_mode_radio.isChecked():
            self.set_foundation_mode()
        else:
            self.set_game_specific_mode()

    def set_foundation_mode(self):
        # Enable input fields
        self.enable_input_fields(True)
        self.architecture_combo.setEnabled(True)
        self.resolution_label.setVisible(False)
        self.resolution_combo.setVisible(False)
        self.game_label.setVisible(False)
        self.game_combo.setVisible(False)
        self.predict_button.setEnabled(True)
        # Set 'Resolution' to '0' internally for foundation model
        self.current_resolution = '0'

    def set_game_specific_mode(self):
        # Disable individual input fields
        self.enable_input_fields(False)
        self.architecture_combo.setEnabled(False)
        self.resolution_label.setVisible(True)
        self.resolution_combo.setVisible(True)
        self.game_label.setVisible(True)
        self.game_combo.setVisible(True)
        self.predict_button.setEnabled(True)

    def enable_input_fields(self, enable):
        fields = [
            self.num_shaders_edit,
            self.clock_speed_edit,
            self.rops_edit,
            self.tmus_edit,
            self.tflops_edit,
            self.l2_cache_edit,
            self.vram_edit,
            self.bandwidth_edit,
        ]
        for field in fields:
            field.layout().itemAt(1).widget().setEnabled(enable)
            field.setEnabled(enable)
        self.architecture_combo.setEnabled(enable)

    def load_default_model(self):
        if os.path.exists(DEFAULT_MODEL_PATH) and os.path.exists(DEFAULT_SCALER_PATH):
            self.load_model_from_path(DEFAULT_MODEL_PATH)
            self.scaler = joblib.load(DEFAULT_SCALER_PATH)

    def load_model(self):
        options = QFileDialog.Option.ReadOnly
        file_dialog = QFileDialog(self, "Load Model", "models/", "PyTorch Models (*.pth)", options=options)
        if file_dialog.exec():
            model_path = file_dialog.selectedFiles()[0]
            self.load_model_from_path(model_path)

    def load_scaler(self):
        options = QFileDialog.Option.ReadOnly
        file_dialog = QFileDialog(self, "Load Scaler", "models/", "Scaler Files (*.pkl)", options=options)
        if file_dialog.exec():
            scaler_path = file_dialog.selectedFiles()[0]
            try:
                self.scaler = joblib.load(scaler_path)
                QMessageBox.information(self, "Success", "Scaler loaded successfully!")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Error loading scaler: {str(e)}")

    def load_model_from_path(self, model_path):
        try:
            config_path = model_path.replace(".pth", ".json")
            with open(config_path, "r") as f:
                self.model_config = json.load(f)

            input_size = self.model_config["model_params"]["input_size"]
            num_architectures = self.model_config["model_params"]["num_architectures"]
            num_resolutions = self.model_config["model_params"]["num_resolutions"]

            self.model = GPUPerformanceModel(input_size, num_architectures, num_resolutions)
            self.model.load_state_dict(torch.load(model_path))
            self.model.eval()
            QMessageBox.information(self, "Success", "Model and config loaded successfully!")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Error loading model or config: {str(e)}")

    def game_selection_changed(self):
        game_name = self.game_combo.currentText()
        if game_name != "Select Game":
            model_path = f"models/{game_name}.pth"
            self.load_model_from_path(model_path)

    def validate_input(self, edit):
        if not edit.hasAcceptableInput():
            edit.setStyleSheet("background-color: pink;")
        else:
            edit.setStyleSheet("")

    def predict_performance(self):
        try:
            # Check if model and scaler are loaded
            if self.model is None:
                QMessageBox.warning(self, "Warning", "Please load a model file.")
                return
            if self.scaler is None:
                QMessageBox.warning(self, "Warning", "Please load a scaler file.")
                return

            if self.foundation_mode_radio.isChecked():
                # Foundation Model Prediction
                # Get input values with validation
                num_shaders = int(self.num_shaders_edit.layout().itemAt(1).widget().text())
                clock_speed = float(self.clock_speed_edit.layout().itemAt(1).widget().text())
                rops = int(self.rops_edit.layout().itemAt(1).widget().text())
                tmus = int(self.tmus_edit.layout().itemAt(1).widget().text())
                l2_cache = float(self.l2_cache_edit.layout().itemAt(1).widget().text())
                vram = float(self.vram_edit.layout().itemAt(1).widget().text())
                bandwidth = float(self.bandwidth_edit.layout().itemAt(1).widget().text())
                tflops = float(self.tflops_edit.layout().itemAt(1).widget().text())
                architecture = ARCHITECTURE_MAPPING[self.architecture_combo.currentText()]
                resolution = 0  # For foundation model

                # Prepare input data
                input_data_num = np.array([[num_shaders, clock_speed, rops, tmus, l2_cache, vram, bandwidth, tflops]])
                input_data_num_scaled = self.scaler.transform(input_data_num)
                input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)

                input_data_cat_tensor = torch.tensor([[architecture, resolution]], dtype=torch.long)
            else:
                # Game-Specific Model Prediction
                resolution_text = self.resolution_combo.currentText()
                resolution = RESOLUTION_MAPPING.get(resolution_text)
                if resolution is None:
                    QMessageBox.warning(self, "Warning", f"Invalid resolution selected: {resolution_text}")
                    return

                gpu_model_name = self.architecture_combo.currentText()
                gpu_spec = self.main_df[self.main_df['GPU model name'] == gpu_model_name]

                if gpu_spec.empty:
                    QMessageBox.warning(self, "Warning", f"GPU {gpu_model_name} not found in data.")
                    return

                # Get specs from main_df
                num_shaders = gpu_spec["Num Shaders"].values[0]
                clock_speed = gpu_spec["Clock Speed (GHz)"].values[0]
                rops = gpu_spec["ROPs"].values[0]
                tmus = gpu_spec["TMUs"].values[0]
                l2_cache = gpu_spec["L2 Cache (MB)"].values[0]
                vram = gpu_spec["VRAM (GB)"].values[0]
                bandwidth = gpu_spec["Bandwidth (GB/s)"].values[0]
                tflops = gpu_spec["TFlops"].values[0]
                architecture_text = gpu_spec["Architecture"].values[0]
                architecture = ARCHITECTURE_MAPPING.get(architecture_text)
                if architecture is None:
                    QMessageBox.warning(self, "Warning", f"Architecture {architecture_text} not recognized.")
                    return

                # Load normalization parameters
                game_name = self.game_combo.currentText()
                norm_params_path = f"models/{game_name}_normalization.json"
                if not os.path.exists(norm_params_path):
                    QMessageBox.warning(self, "Warning", f"Normalization parameters not found for {game_name}.")
                    return
                with open(norm_params_path, "r") as f:
                    normalization_params = json.load(f)
                fps_min = normalization_params['fps_min']
                fps_max = normalization_params['fps_max']

                # Prepare input data
                input_data_num = np.array([[num_shaders, clock_speed, rops, tmus, l2_cache, vram, bandwidth, tflops]])
                input_data_num_scaled = self.scaler.transform(input_data_num)
                input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)

                input_data_cat_tensor = torch.tensor([[architecture, resolution]], dtype=torch.long)

            # Make prediction
            with torch.no_grad():
                prediction_normalized = self.model(input_data_num_tensor, input_data_cat_tensor).item()

            # Rescale prediction back to original FPS
            if fps_max == fps_min:
                prediction = fps_min  # All FPS values are the same
            else:
                prediction = (prediction_normalized - 0.3) / (7 - 0.3)
                prediction = prediction * (fps_max - fps_min) + fps_min

            # Display the prediction
            self.output_label.setText(
                f"Predicted FPS: {prediction:.2f} fps at {self.resolution_combo.currentText()}"
            )
            # Update the progress bar
            performance_percentage = int(min(max(prediction / 240.0 * 100, 0), 100))
            self.performance_bar.setValue(performance_percentage)

        except ValueError as e:
            QMessageBox.critical(self, "Error", f"Invalid input: {str(e)}")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"An error occurred during prediction: {str(e)}")

    def batch_predict(self):
        options = QFileDialog.Option.ReadOnly
        file_dialog = QFileDialog(self, "Select Batch Prediction File", "", "CSV Files (*.csv)", options=options)
        if file_dialog.exec():
            file_path = file_dialog.selectedFiles()[0]
            try:
                batch_df = pd.read_csv(file_path)

                # Prepare data
                if self.foundation_mode_radio.isChecked():
                    required_columns = NUMERIC_FEATURES + ["Architecture"]
                    if not all(col in batch_df.columns for col in required_columns):
                        QMessageBox.warning(self, "Warning", f"Batch file must contain columns: {required_columns}")
                        return

                    input_data_num = batch_df[NUMERIC_FEATURES].values
                    input_data_num_scaled = self.scaler.transform(input_data_num)
                    input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)

                    batch_df["Architecture"] = batch_df["Architecture"].map(ARCHITECTURE_MAPPING)
                    input_data_cat = np.column_stack((
                        batch_df["Architecture"].values.astype(int),
                        np.zeros(len(batch_df), dtype=int)  # Resolution set to 0
                    ))
                    input_data_cat_tensor = torch.tensor(input_data_cat, dtype=torch.long)
                else:
                    required_columns = ["GPU model name", "Resolution"]
                    if not all(col in batch_df.columns for col in required_columns):
                        QMessageBox.warning(self, "Warning", f"Batch file must contain columns: {required_columns}")
                        return

                    input_data_num_list = []
                    input_data_cat_list = []
                    for idx, row in batch_df.iterrows():
                        gpu_model_name = row["GPU model name"]
                        resolution_text = row["Resolution"]
                        resolution = RESOLUTION_MAPPING.get(resolution_text)
                        if resolution is None:
                            QMessageBox.warning(self, "Warning", f"Invalid resolution: {resolution_text}")
                            return

                        gpu_spec = self.main_df[self.main_df['GPU model name'] == gpu_model_name]
                        if gpu_spec.empty:
                            QMessageBox.warning(self, "Warning", f"GPU {gpu_model_name} not found in data.")
                            continue

                        num_shaders = gpu_spec["Num Shaders"].values[0]
                        clock_speed = gpu_spec["Clock Speed (GHz)"].values[0]
                        rops = gpu_spec["ROPs"].values[0]
                        tmus = gpu_spec["TMUs"].values[0]
                        l2_cache = gpu_spec["L2 Cache (MB)"].values[0]
                        vram = gpu_spec["VRAM (GB)"].values[0]
                        bandwidth = gpu_spec["Bandwidth (GB/s)"].values[0]
                        tflops = gpu_spec["TFlops"].values[0]
                        architecture_text = gpu_spec["Architecture"].values[0]
                        architecture = ARCHITECTURE_MAPPING.get(architecture_text)
                        if architecture is None:
                            QMessageBox.warning(self, "Warning", f"Architecture {architecture_text} not recognized.")
                            continue

                        input_num = [
                            num_shaders, clock_speed, rops, tmus, l2_cache, vram, bandwidth, tflops
                        ]
                        input_data_num_list.append(input_num)
                        input_data_cat_list.append([architecture, resolution])

                    if not input_data_num_list:
                        QMessageBox.warning(self, "Warning", "No valid data for batch prediction.")
                        return

                    input_data_num = np.array(input_data_num_list)
                    input_data_num_scaled = self.scaler.transform(input_data_num)
                    input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)
                    input_data_cat_tensor = torch.tensor(input_data_cat_list, dtype=torch.long)

                # Make predictions
                with torch.no_grad():
                    predictions = self.model(input_data_num_tensor, input_data_cat_tensor).numpy().flatten()

                # Save predictions to CSV
                batch_df['Prediction'] = predictions
                save_path, _ = QFileDialog.getSaveFileName(self, "Save Predictions", "", "CSV Files (*.csv)")
                if save_path:
                    batch_df.to_csv(save_path, index=False)
                    QMessageBox.information(self, "Success", f"Predictions saved to {save_path}")

            except Exception as e:
                QMessageBox.critical(self, "Error", f"Error during batch prediction: {str(e)}")

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = PredictionApp()
    window.show()
    sys.exit(app.exec())   ; Error: An error occured during prediction: cannot access local variable 'fps_max' where it is not associated with a value ; This happens when we load the foundation model and try to predict the performance of a given spec, the foundation model does not  need fps max or any of that because that parameter belongs to the game specific models, here is the code we used to train all the models: import json
import os
import glob
import random

import joblib
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from torch.utils.data import Dataset, DataLoader

# --- Set Random Seed for Reproducibility ---
def set_seed(seed):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    # Ensure deterministic behavior (may slow down training)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

set_seed(42)

# --- Load Data from CSV ---
def load_data(csv_file):
    df = pd.read_csv(csv_file)
    # Assume GPU model names match and are correct
    # Keep 'Architecture' in uppercase
    return df

# --- Data Preprocessing for Foundation Model ---
def preprocess_data(df):
    numeric_features = [
        "Num Shaders",
        "Clock Speed (GHz)",
        "ROPs",
        "TMUs",
        "L2 Cache (MB)",
        "VRAM (GB)",
        "Bandwidth (GB/s)",
        "TFlops",
    ]
    categorical_features = ["Architecture", "Resolution"]
    
    # Map 'Architecture' without changing case
    ARCHITECTURE_MAPPING = {
        "PASCAL": 0,
        "TURING": 1,
        "AMPERE": 2,
        "ADA": 3,
        "GCN5": 4,
        "RDNA": 5,
        "RDNA2": 6,
        "RDNA3": 7,
    }

    df["Architecture"] = df["Architecture"].map(ARCHITECTURE_MAPPING)
    df = df.dropna(subset=["Architecture"])

    # Add 'Resolution' as a categorical feature, set to 0 for all entries
    df["Resolution"] = 0

    target = "Relative Performance"

    # Standardize numerical features
    scaler = StandardScaler()
    X_num = scaler.fit_transform(df[numeric_features])

    X_cat = df[categorical_features].values
    y = df[target].values

    # Convert to tensors
    X_num = torch.tensor(X_num, dtype=torch.float32)
    X_cat = torch.tensor(X_cat, dtype=torch.long)
    y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)  # Unsqueeze for single target

    return X_num, X_cat, y, scaler

# --- Data Preprocessing for Game-Specific Models ---
def preprocess_game_data(game_df, main_df, scaler):
    # Merge game data with main data to get full features
    merged_df = pd.merge(game_df, main_df, on='GPU model name', how='inner')
    print(f"Merged data shape after merge: {merged_df.shape}")

    if merged_df.empty:
        print("No matching GPUs found after merging; skipping this game.")
        return None

    # Print unique 'Resolution' values before mapping
    print("Unique 'Resolution' values before mapping:", merged_df['Resolution'].unique())

    # Map resolutions to integers
    res_mapping = {' 1080p': 1, ' 1440p': 2, ' 4K': 3}
    merged_df['Resolution'] = merged_df['Resolution'].map(res_mapping)

    # Print unique 'Resolution' values after mapping
    print("Unique 'Resolution' values after mapping:", merged_df['Resolution'].unique())

    # Check for NaN values in 'Resolution'
    num_nan_res = merged_df['Resolution'].isna().sum()
    print(f"Number of NaN values in 'Resolution' after mapping: {num_nan_res}")

    # Drop rows with NaN in 'Resolution'
    merged_df = merged_df.dropna(subset=['Resolution'])

    # Similarly for 'Architecture'
    print("Unique 'Architecture' values before mapping:", merged_df['Architecture'].unique())

    # Map 'Architecture' without changing case
    ARCHITECTURE_MAPPING = {
        "PASCAL": 0,
        "TURING": 1,
        "AMPERE": 2,
        "ADA": 3,
        "GCN5": 4,
        "RDNA": 5,
        "RDNA2": 6,
        "RDNA3": 7,
    }
    merged_df["Architecture"] = merged_df["Architecture"].map(ARCHITECTURE_MAPPING)

    # Print unique 'Architecture' values after mapping
    print("Unique 'Architecture' values after mapping:", merged_df["Architecture"].unique())

    # Check for NaN values in 'Architecture'
    num_nan_arch = merged_df['Architecture'].isna().sum()
    print(f"Number of NaN values in 'Architecture' after mapping: {num_nan_arch}")

    # Drop rows where 'Architecture' is NaN
    merged_df = merged_df.dropna(subset=['Architecture'])

    # Convert 'Architecture' and 'Resolution' to integers
    merged_df["Architecture"] = merged_df["Architecture"].astype(int)
    merged_df["Resolution"] = merged_df["Resolution"].astype(int)

    # Prepare inputs and target
    numeric_features = [
        "Num Shaders",
        "Clock Speed (GHz)",
        "ROPs",
        "TMUs",
        "L2 Cache (MB)",
        "VRAM (GB)",
        "Bandwidth (GB/s)",
        "TFlops",
    ]

    # Check for missing values in numeric features
    num_missing_numeric = merged_df[numeric_features].isna().sum()
    print("Number of missing values in numeric features:\n", num_missing_numeric)

    # Drop rows with missing numeric features
    merged_df = merged_df.dropna(subset=numeric_features)

    print(f"Merged data shape after preprocessing: {merged_df.shape}")
    print(f"Sample of merged data:\n{merged_df.head()}")

    if merged_df.empty:
        print("No valid data after preprocessing; skipping this game.")
        return None
    
    # Normalize 'Performance (FPS)'
    fps_values = merged_df['Performance (FPS)'].values
    fps_min = fps_values.min()
    fps_max = fps_values.max()

    # Avoid division by zero in case fps_max == fps_min
    if fps_max == fps_min:
        fps_normalized = np.zeros_like(fps_values)
    else:
        fps_normalized = (fps_values - fps_min) / (fps_max - fps_min)
        # Scale to approximate foundation model range
        fps_normalized = fps_normalized * (7 - 0.3) + 0.3

    y = torch.tensor(fps_normalized, dtype=torch.float32).unsqueeze(1)

    # Save normalization parameters for later use
    normalization_params = {'fps_min': fps_min, 'fps_max': fps_max}

    # Use the same scaler
    X_num = scaler.transform(merged_df[numeric_features])
    X_num = torch.tensor(X_num, dtype=torch.float32)

    # Combine categorical features
    X_cat_arch = torch.tensor(merged_df["Architecture"].values, dtype=torch.long)
    X_cat_res = torch.tensor(merged_df["Resolution"].values, dtype=torch.long)
    X_cat = torch.stack([X_cat_arch, X_cat_res], dim=1)  # Shape: [num_samples, 2]

    y = torch.tensor(merged_df['Performance (FPS)'].values, dtype=torch.float32).unsqueeze(1)

    return X_num, X_cat, y, normalization_params

# --- Custom Dataset ---
class GPUDataset(Dataset):
    def __init__(self, X_num, X_cat, y):
        self.X_num = X_num
        self.X_cat = X_cat
        self.y = y

    def __len__(self):
        return len(self.y)

    def __getitem__(self, idx):
        return self.X_num[idx], self.X_cat[idx], self.y[idx]

# --- Model Architecture ---
class GPUPerformanceModel(nn.Module):
    def __init__(self, input_size, num_architectures, num_resolutions):
        super(GPUPerformanceModel, self).__init__()
        embedding_dim_arch = 4
        embedding_dim_res = 2
        self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=embedding_dim_arch)
        self.res_embedding = nn.Embedding(num_resolutions, embedding_dim=embedding_dim_res)
        total_embedding_dim = embedding_dim_arch + embedding_dim_res

        self.model = nn.Sequential(
            nn.Linear(input_size + total_embedding_dim, 128),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(64, 1)
        )

    def forward(self, x_num, x_cat):
        x_arch = self.arch_embedding(x_cat[:, 0])
        x_res = self.res_embedding(x_cat[:, 1])
        x_cat = torch.cat([x_arch, x_res], dim=1)
        x = torch.cat([x_num, x_cat], dim=1)
        return self.model(x)

# --- Training Function ---
def train_model(model, train_loader, val_loader, epochs, learning_rate, device):
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)

    train_losses = []
    val_losses = []

    for epoch in range(epochs):
        # Training
        model.train()
        epoch_train_loss = 0
        for X_num_batch, X_cat_batch, y_batch in train_loader:
            X_num_batch = X_num_batch.to(device)
            X_cat_batch = X_cat_batch.to(device)
            y_batch = y_batch.to(device)

            optimizer.zero_grad()
            outputs = model(X_num_batch, X_cat_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()

            epoch_train_loss += loss.item() * X_num_batch.size(0)

        train_losses.append(epoch_train_loss / len(train_loader.dataset))

        # Validation
        model.eval()
        epoch_val_loss = 0
        with torch.no_grad():
            for X_num_batch, X_cat_batch, y_batch in val_loader:
                X_num_batch = X_num_batch.to(device)
                X_cat_batch = X_cat_batch.to(device)
                y_batch = y_batch.to(device)

                outputs = model(X_num_batch, X_cat_batch)
                loss = criterion(outputs, y_batch)

                epoch_val_loss += loss.item() * X_num_batch.size(0)

        val_losses.append(epoch_val_loss / len(val_loader.dataset))

        # Print epoch statistics
        if (epoch + 1) % 50 == 0:
            print(
                f"Epoch [{epoch+1}/{epochs}], "
                f"Train Loss: {train_losses[-1]:.4f}, Val Loss: {val_losses[-1]:.4f}"
            )

    return train_losses, val_losses

# --- Main Function ---
def main():
    # Device configuration
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # Load main data
    main_df = load_data("gpu_data.csv")

    # Preprocess main data
    X_num, X_cat, y, scaler = preprocess_data(main_df.copy())

    # Split data into training and validation sets
    X_num_train, X_num_val, X_cat_train, X_cat_val, y_train, y_val = train_test_split(
        X_num, X_cat, y, test_size=0.1, random_state=42
    )

    # Create datasets and loaders
    train_dataset = GPUDataset(X_num_train, X_cat_train, y_train)
    val_dataset = GPUDataset(X_num_val, X_cat_val, y_val)
    batch_size = 16
    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
    val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)

    # Model Parameters
    input_size = X_num.shape[1]
    num_architectures = len(set(main_df['Architecture'].dropna()))
    num_resolutions = 4  # 0 for foundation model, 1-3 for game-specific models

    # Training Parameters
    epochs = 1000
    learning_rate = 0.0011

    # Initialize foundation model
    foundation_model = GPUPerformanceModel(
        input_size=input_size,
        num_architectures=num_architectures,
        num_resolutions=num_resolutions
    ).to(device)

    # Train foundation model
    train_losses, val_losses = train_model(
        foundation_model, train_loader, val_loader, epochs=epochs, learning_rate=learning_rate, device=device
    )

    # Save foundation model and scaler
    model_name = "Foundation_Model"
    os.makedirs("models", exist_ok=True)
    torch.save(foundation_model.state_dict(), f"models/{model_name}.pth")
    joblib.dump(scaler, f"models/{model_name}_scaler.pkl")

    # Save model configuration
    model_config = {
        "model_params": {
            "input_size": input_size,
            "num_architectures": num_architectures,
            "num_resolutions": num_resolutions,
            "embedding_dim_arch": 4,
            "embedding_dim_res": 2,
            "layers": [128, 64, 1],
            "dropout": 0.125,
        },
        "training_params": {
            "epochs": epochs,
            "optimizer": "Adam",
            "learning_rate": learning_rate,
            "loss_function": "MSELoss",
            "batch_size": batch_size,
            "scheduler": None,
        },
        "validation_loss": val_losses[-1],
    }
    with open(f"models/{model_name}.json", "w") as f:
        json.dump(model_config, f)

    # Fine-tune models on game-specific datasets
    game_data_files = glob.glob("game_data/*.csv")  # Assuming game data is in 'game_data' folder
    for game_file in game_data_files:
        game_name = os.path.splitext(os.path.basename(game_file))[0]
        game_df = pd.read_csv(game_file)

        # Preprocess game data
        result = preprocess_game_data(game_df, main_df, scaler)
        if result is None:
            print(f"No valid data for {game_name}; skipping...")
            continue
        X_num_game, X_cat_game, y_game, normalization_params = result

        # Split game data
        X_num_train_g, X_num_val_g, X_cat_train_g, X_cat_val_g, y_train_g, y_val_g = train_test_split(
            X_num_game, X_cat_game, y_game, test_size=0.1, random_state=42
        )

        # Create datasets and loaders
        train_dataset_g = GPUDataset(X_num_train_g, X_cat_train_g, y_train_g)
        val_dataset_g = GPUDataset(X_num_val_g, X_cat_val_g, y_val_g)
        train_loader_g = DataLoader(train_dataset_g, batch_size=batch_size, shuffle=True)
        val_loader_g = DataLoader(val_dataset_g, batch_size=batch_size, shuffle=False)

        # Initialize game-specific model with same architecture
        game_model = GPUPerformanceModel(
            input_size=input_size,
            num_architectures=num_architectures,
            num_resolutions=num_resolutions
        ).to(device)

        # Initialize game-specific model with foundation model weights
        game_model_dict = game_model.state_dict()
        foundation_model_dict = foundation_model.state_dict()

        # Update weights except for 'res_embedding.weight', since foundation model used resolution=0
        filtered_dict = {k: v for k, v in foundation_model_dict.items() if k in game_model_dict and 'res_embedding' not in k}
        game_model_dict.update(filtered_dict)
        game_model.load_state_dict(game_model_dict)

        # Train game-specific model
        epochs_game = 400  # Fewer epochs for fine-tuning
        train_losses_g, val_losses_g = train_model(
            game_model, train_loader_g, val_loader_g, epochs=epochs_game, learning_rate=learning_rate, device=device
        )

        # Save game-specific model
        torch.save(game_model.state_dict(), f"models/{game_name}.pth")

        # Save model configuration
        model_config_g = {
            "model_params": {
                "input_size": input_size,
                "num_architectures": num_architectures,
                "num_resolutions": num_resolutions,
                "embedding_dim_arch": 4,
                "embedding_dim_res": 2,
                "layers": [128, 64, 1],
                "dropout": 0.125,
            },
            "training_params": {
                "epochs": epochs_game,
                "optimizer": "Adam",
                "learning_rate": learning_rate,
                "loss_function": "MSELoss",
                "batch_size": batch_size,
                "scheduler": None,
            },
            "validation_loss": val_losses_g[-1],
        }
        with open(f"models/{game_name}.json", "w") as f:
            json.dump(model_config_g, f)
        # Save normalization parameters
        with open(f"models/{game_name}_normalization.json", "w") as f:
            json.dump(normalization_params, f)

        print(f"Model for {game_name} trained and saved.")

    print("All models trained and saved successfully.")
if __name__ == "__main__":
    main()