diff --git a/electron-builder.yml b/electron-builder.yml index 519fea98..2399a4fb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -37,6 +37,10 @@ extraResources: # Add this section if it doesn't exist, or add the line to it - "**/*" - "!**/.git" - "!**/.git/**" + - from: ./baseFiles # Source directory + to: baseFiles # Destination directory inside Contents/Resources + filter: + - emptyNotebook.ipynb extends: null # Added from package.json mac: # Added from package.json diff --git a/go_server/src/utils.go b/go_server/src/utils.go index f5616e1c..c682c641 100644 --- a/go_server/src/utils.go +++ b/go_server/src/utils.go @@ -163,14 +163,6 @@ func StartPythonScripts(jsonParam string, filename string, id string) (string, e } log.Println("Conda env: " + condaEnv) - // UNCOMMENT TO WRITE JSON PARAM TO FILE FOR DEBUGGING - // jsonParamBytes := []byte(jsonParam) - // err = os.WriteFile("jsonParam.txt", jsonParamBytes, 0644) - // if err != nil { - // log.Println("Error writing jsonParam to file") - // return "", err - // } - Scripts[id] = ScriptInfo{ Cmd: exec.Command(condaEnv, "-u", script, "--json-param", jsonParam, "--id", id), Progress: "", @@ -178,15 +170,13 @@ func StartPythonScripts(jsonParam string, filename string, id string) (string, e stdout, err := Scripts[id].Cmd.StdoutPipe() Mu.Unlock() if err != nil { - log.Println("Error getting stdout pipe") - log.Println(err.Error()) + log.Println("Error getting stdout pipe: " + err.Error()) } Mu.Lock() stderr, err := Scripts[id].Cmd.StderrPipe() Mu.Unlock() if err != nil { - log.Println("Error getting stderr pipe") - log.Println(err.Error()) + log.Println("Error getting stderr pipe: " + err.Error()) } Mu.Lock() err = Scripts[id].Cmd.Start() @@ -195,51 +185,138 @@ func StartPythonScripts(jsonParam string, filename string, id string) (string, e log.Println("Error starting command " + Scripts[id].Cmd.String()) return "", err } + response := "" - go copyOutput(stdout, &response) - go copyOutput(stderr, &response) - err = Scripts[id].Cmd.Wait() - if err != nil { - log.Println("Error waiting for command to finish") - return "", err + // stderrBuf collects every line from stderr so we can surface it on early + // crashes (e.g. ModuleNotFoundError) that never reach send_response(). + var stderrBuf strings.Builder + var stderrMu sync.Mutex + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + copyOutput(stdout, &response) + }() + go func() { + defer wg.Done() + copyOutputErr(stderr, &response, &stderrBuf, &stderrMu) + }() + wg.Wait() + + waitErr := Scripts[id].Cmd.Wait() + + // Happy path – the Python layer called send_response() successfully. + if response != "" { + log.Println("Finished running script: " + filename + " with id: " + id) + return response, nil } - log.Println("Finished running script: " + filename + " with id: " + id) - return response, nil + + // The script exited without ever emitting response-ready (early crash). + // Build a structured error JSON that matches get_response_from_error() so + // the frontend receives consistent error objects regardless of where in + // Python the failure happened. + stderrMu.Lock() + capturedStderr := stderrBuf.String() + stderrMu.Unlock() + + exitMsg := "unknown error" + if waitErr != nil { + exitMsg = waitErr.Error() + } + if capturedStderr == "" { + capturedStderr = exitMsg + } + + log.Println("Script crashed before send_response. stderr:\n" + capturedStderr) + + errorPayload := map[string]interface{}{ + "error": map[string]string{ + "message": exitMsg, + "stack_trace": capturedStderr, + "value": capturedStderr, + }, + } + jsonBytes, jsonErr := json.Marshal(errorPayload) + if jsonErr != nil { + // Last-resort fallback – should never happen. + return "", waitErr + } + return string(jsonBytes), nil } -// It is used to transfer stdout and stderr to the terminal +// copyOutput handles stdout: detects response-ready and progress signals. func copyOutput(r io.Reader, response *string) { scanner := bufio.NewScanner(r) - lineText := "" for scanner.Scan() { - lineText = scanner.Text() + lineText := scanner.Text() if strings.Contains(lineText, "response-ready*_*") { path := strings.Split(lineText, "*_*")[1] path = path[:len(path)-1] *response = ReadFile(path) - // delete this file - err := os.Remove(path) - if err != nil { + if err := os.Remove(path); err != nil { log.Println(err) } } else if strings.Contains(lineText, "progress*_*") { - id := strings.Split(lineText, "*_*")[1] - progress := strings.Split(lineText, "*_*")[2] - progress = progress[:len(progress)-1] - log.Println("Progress: " + progress) - Mu.Lock() - Scripts[id] = ScriptInfo{ - Cmd: Scripts[id].Cmd, - Progress: progress, + parts := strings.Split(lineText, "*_*") + if len(parts) >= 3 { + pid := parts[1] + progress := parts[2][:len(parts[2])-1] + log.Println("Progress: " + progress) + Mu.Lock() + Scripts[pid] = ScriptInfo{ + Cmd: Scripts[pid].Cmd, + Progress: progress, + } + Mu.Unlock() + } + } + } +} + +// copyOutputErr mirrors copyOutput for stderr. +// It still watches for response-ready/progress (some Python loggers write to +// stderr) and additionally accumulates every line into stderrBuf so early +// crashes (ImportError, SyntaxError, …) are never silently dropped. +func copyOutputErr(r io.Reader, response *string, stderrBuf *strings.Builder, mu *sync.Mutex) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + lineText := scanner.Text() + log.Println("[stderr] " + lineText) + + // Accumulate for crash reporting. + mu.Lock() + stderrBuf.WriteString(lineText + "\n") + mu.Unlock() + + // Handle structured signals even if they arrive on stderr. + if strings.Contains(lineText, "response-ready*_*") { + path := strings.Split(lineText, "*_*")[1] + path = path[:len(path)-1] + *response = ReadFile(path) + if err := os.Remove(path); err != nil { + log.Println(err) + } + } else if strings.Contains(lineText, "progress*_*") { + parts := strings.Split(lineText, "*_*") + if len(parts) >= 3 { + pid := parts[1] + progress := parts[2][:len(parts[2])-1] + Mu.Lock() + Scripts[pid] = ScriptInfo{ + Cmd: Scripts[pid].Cmd, + Progress: progress, + } + Mu.Unlock() } - Mu.Unlock() } } } // ReadFile reads a file and returns its content as a string func ReadFile(filename string) string { - absPath, _ := filepath.Abs(filename) + cleanPath := strings.Trim(filename, "\" \t\n\r") + absPath, _ := filepath.Abs(cleanPath) log.Println("Reading file: " + absPath) data, err := os.ReadFile(absPath) if err != nil { @@ -295,8 +372,8 @@ func KillScript(id string) bool { } }() if script.Cmd != nil { // Check if script.Cmd is not nil - if script.Cmd.ProcessState != nil && script.Cmd.ProcessState.Exited() { - log.Println("Script can be killed") + if script.Cmd.Process != nil && (script.Cmd.ProcessState == nil || !script.Cmd.ProcessState.Exited()) { + log.Println("Script is running, killing it now...") err := script.Cmd.Process.Kill() if err != nil { log.Print("Error killing process: ", err.Error()) diff --git a/pythonCode/med_libs/MEDml/MEDexperiment.py b/pythonCode/med_libs/MEDml/MEDexperiment.py index a2ccfb96..2f053a2a 100644 --- a/pythonCode/med_libs/MEDml/MEDexperiment.py +++ b/pythonCode/med_libs/MEDml/MEDexperiment.py @@ -62,6 +62,7 @@ def __init__(self, global_json_config: json = None): self.finalize_node = global_json_config.get('modelToFinalize', None) self.finalize_is_combine = self.__get_node_type(self.finalize_node) == 'combine_models' if self.finalize_node is not None else False self.pipelines = self.__init_pipelines(global_json_config['pipelines']) + self.pipelines = self.split_pipelines(self.pipelines) self.pipelines_to_execute = self.pipelines self._results_pipeline = {} self._progress = {'currentLabel': '', 'now': 0.0} @@ -106,6 +107,30 @@ def __get_node_type(self, node_id: str) -> str: else: raise ValueError(f"Node {node_id} not found in global json config.") + def split_pipelines(self, data): + if not isinstance(data, dict): + return data + + new_dict = {} + for key, value in data.items(): + if "." in key: + # Split the key into the part before and after the dot + first_part, second_part = key.split(".", 1) + + # Extract the prefix before the '*' to reconstruct the second key + prefix = first_part.split("*")[0] + "*" + reconstructed_second_key = prefix + second_part + + # Recursively process the children for both new keys + processed_value = self.split_pipelines(value) + new_dict[first_part] = processed_value + new_dict[reconstructed_second_key] = processed_value + else: + # Recurse deeper into the dictionary + new_dict[key] = self.split_pipelines(value) + + return new_dict + def update(self, global_json_config: json = None): """Updates the experiment with the pipelines and the global configuration. @@ -199,7 +224,10 @@ def start(self) -> None: for current_node_id, next_nodes_id_json in self.pipelines_to_execute.items(): node_info = self.pipelines_objects[current_node_id] node: Node = node_info['obj'] - self._progress['currentLabel'] = node.username + if node.username.lower() == 'train model': + self._progress['currentLabel'] = f"Training model (This process may take long)" + else: + self._progress['currentLabel'] = node.username has_been_run = node.has_run() if not has_been_run or 'experiment' not in node_info: node_info['results'] = { @@ -283,7 +311,10 @@ def execute_next_nodes( node = node_info['obj'] experiment = self.copy_experiment(experiment) exp_to_return = experiment - self._progress['currentLabel'] = node.username + if node.username.lower() == 'train model': + self._progress['currentLabel'] = f"Training model (This process may take long)" + else: + self._progress['currentLabel'] = node.username if not node.has_run() or prev_node.has_changed(): if node.type == 'group_models': print("group_models") diff --git a/pythonCode/med_libs/MEDml/nodes/ModelHandler.py b/pythonCode/med_libs/MEDml/nodes/ModelHandler.py index 85ae8f1a..e00bd537 100644 --- a/pythonCode/med_libs/MEDml/nodes/ModelHandler.py +++ b/pythonCode/med_libs/MEDml/nodes/ModelHandler.py @@ -1,3 +1,4 @@ +import ast import copy import json from typing import Union @@ -68,14 +69,16 @@ class ModelHandler(Node): """ def __init__(self, id_: int, global_config_json: json) -> None: - """ - Args: - id_ (int): The id of the node. - global_config_json (json): The global config json. - """ super().__init__(id_, global_config_json) self.model_name_id = None if self.type == 'train_model': + self.model_id = self.config_json['associated_id'] + model_obj = self.global_config_json['nodes'][self.model_id] + self.model_name_id = model_obj['data']['internal'].get('nameID', None) + self.config_json['data']['estimator'] = { + "type": model_obj['data']['internal']['selection'], + "settings": model_obj['data']['internal']['settings'] + } self.isTuningEnabled = self.config_json['data']['internal'].get('isTuningEnabled', False) if self.isTuningEnabled: self.settingsTuning = self.config_json['data']['internal'].get('settingsTuning', {}) @@ -89,29 +92,27 @@ def __init__(self, id_: int, global_config_json: json) -> None: self.optimize_threshold = self.config_json['data']['internal'].get('optimizeThreshold', False) if self.optimize_threshold: self.threshold_optimization_metric = self.config_json['data']['internal'].get('threshOptimizationMetric', 'Accuracy') - self.model_id = self.config_json['associated_id'] - model_obj = self.global_config_json['nodes'][self.model_id] - self.model_name_id = model_obj['data']['internal'].get('nameID', None) - self.config_json['data']['estimator'] = { - "type": model_obj['data']['internal']['selection'], - "settings": model_obj['data']['internal']['settings'] - } + # Normalizing metric names for Pycaret + METRIC_NAME_MAP = { + 'Youden': 'Youden Index', + 'BAC': 'Balanced Accuracy', + 'Specificity': 'Specificity', + 'NPV': 'NPV', + } + self.threshold_optimization_metric = METRIC_NAME_MAP.get( + self.threshold_optimization_metric, + self.threshold_optimization_metric + ) def __calculate_all_metrics(self, y_true, y_pred, y_pred_proba=None): - """ - Calculate comprehensive classification metrics manually - """ metrics = {} try: - # Probability-based metrics (if available) if y_pred_proba is not None: try: - # For binary classification if len(np.unique(y_true)) == 2: metrics['AUC'] = round(roc_auc_score(y_true, y_pred_proba), 3) else: - # For multiclass - use one-vs-rest metrics['AUC'] = round(roc_auc_score(y_true, y_pred_proba, multi_class='ovr', average='weighted'), 3) except Exception as e: print(f"Warning: Could not calculate probability metrics: {e}") @@ -119,35 +120,50 @@ def __calculate_all_metrics(self, y_true, y_pred, y_pred_proba=None): else: metrics['AUC'] = "N/A" - # Basic classification metrics metrics['Sensitivity'] = round(recall_score(y_true, y_pred, zero_division=0), 3) metrics['Specificity'] = round(self.specificity(y_true, y_pred), 3) metrics['PPV'] = round(precision_score(y_true, y_pred, zero_division=0), 3) metrics['NPV'] = round(self.npv(y_true, y_pred), 3) metrics['Accuracy'] = round(accuracy_score(y_true, y_pred), 3) metrics['F1'] = round(f1_score(y_true, y_pred, zero_division=0), 3) - - # Additional metrics metrics['MCC'] = round(matthews_corrcoef(y_true, y_pred), 3) except Exception as e: - print(f"Error calculating metrics: {e}") - # Set default values for all metrics - default_metrics = ["AUC", "Sensitivity", "Specificity", "PPV", "NPV", "Accuracy", "F1", "MCC"] - for metric in default_metrics: - metrics[metric] = "N/A" + raise ValueError(f"Error calculating metrics: {e}") return metrics + + def __calculate_all_metrics_to_notebook(self): + self.CodeHandler.add_line("code", "def calculate_all_metrics(y_true, y_pred, y_pred_proba=None):", indent=0) + self.CodeHandler.add_line("code", "metrics = {}", indent=1) + self.CodeHandler.add_line("code", "try:", indent=1) + self.CodeHandler.add_line("code", "if y_pred_proba is not None:", indent=2) + self.CodeHandler.add_line("code", "try:", indent=3) + self.CodeHandler.add_line("code", "if len(np.unique(y_true)) == 2:", indent=4) + self.CodeHandler.add_line("code", "metrics['AUC'] = round(roc_auc_score(y_true, y_pred_proba), 3)", indent=5) + self.CodeHandler.add_line("code", "else:", indent=4) + self.CodeHandler.add_line("code", "metrics['AUC'] = round(roc_auc_score(y_true, y_pred_proba, multi_class='ovr', average='weighted'), 3)", indent=5) + self.CodeHandler.add_line("code", "except Exception as e:", indent=3) + self.CodeHandler.add_line("code", "print(f\"Warning: Could not calculate probability metrics: {e}\")", indent=4) + self.CodeHandler.add_line("code", "metrics['AUC'] = \"N/A\"", indent=4) + self.CodeHandler.add_line("code", "metrics['Sensitivity'] = round(recall_score(y_true, y_pred, zero_division=0), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['Specificity'] = round(specificity(y_true, y_pred), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['PPV'] = round(precision_score(y_true, y_pred, zero_division=0), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['NPV'] = round(npv(y_true, y_pred), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['Accuracy'] = round(accuracy_score(y_true, y_pred), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['F1'] = round(f1_score(y_true, y_pred, zero_division=0), 3)", indent=2) + self.CodeHandler.add_line("code", "metrics['MCC'] = round(matthews_corrcoef(y_true, y_pred), 3)", indent=2) + self.CodeHandler.add_line("code", "except Exception as e:", indent=1) + self.CodeHandler.add_line("code", "raise ValueError(f\"Error calculating metrics: {e}\")", indent=2) + self.CodeHandler.add_line("code", "return metrics", indent=1) def __calculate_overall_metrics(self, fold_metrics): - """Calculate mean and std of metrics across all folds""" overall_metrics = {} log_metrics = {} if not fold_metrics: - return overall_metrics + return overall_metrics, log_metrics - # Get all metric names from first fold first_fold_metrics = list(fold_metrics.values())[0] for metric_name in first_fold_metrics.keys(): @@ -168,56 +184,107 @@ def __calculate_overall_metrics(self, fold_metrics): return overall_metrics, log_metrics + def __calculate_overall_metrics_to_notebook(self): + self.CodeHandler.add_line("code", "def calculate_overall_metrics(fold_metrics):", indent=0) + self.CodeHandler.add_line("code", "overall_metrics = {}", indent=1) + self.CodeHandler.add_line("code", "log_metrics = {}", indent=1) + self.CodeHandler.add_line("code", "if not fold_metrics:", indent=1) + self.CodeHandler.add_line("code", "return overall_metrics, log_metrics", indent=2) + self.CodeHandler.add_line("code", "first_fold_metrics = list(fold_metrics.values())[0]", indent=1) + self.CodeHandler.add_line("code", "for metric_name in first_fold_metrics.keys():", indent=1) + self.CodeHandler.add_line("code", "metric_values = []", indent=2) + self.CodeHandler.add_line("code", "for _, metrics in fold_metrics.items():", indent=2) + self.CodeHandler.add_line("code", "if metric_name in list(metrics.keys()) and \"N/A\" not in str(metrics[metric_name]):", indent=3) + self.CodeHandler.add_line("code", "metric_values.append(metrics[metric_name])", indent=4) + self.CodeHandler.add_line("code", "if metric_values:", indent=2) + self.CodeHandler.add_line("code", "overall_metrics[metric_name] = { 'mean': round(float(np.mean(metric_values)), 3), 'median': round(float(np.median(metric_values)), 3), 'std': round(float(np.std(metric_values)), 3), 'min': round(float(np.min(metric_values)), 3), 'max': round(float(np.max(metric_values)), 3) }", indent=3) + self.CodeHandler.add_line("code", "log_metrics[metric_name] = overall_metrics[metric_name]['mean']", indent=3) + self.CodeHandler.add_line("code", "return overall_metrics, log_metrics", indent=1) + + def __recalculate_metrics_with_threshold(self, model, X_test, y_test): + """ + Recalculate all metrics using the model's optimized threshold. + Reads 'probability_threshold' (PyCaret 3.x CustomProbabilityThresholdClassifier) + with fallback to 'threshold' for compatibility with other versions. + Used after optimize_threshold to ensure reported metrics reflect the actual threshold. + """ + threshold = getattr(model, 'probability_threshold', getattr(model, 'threshold', 0.5)) + y_proba = model.predict_proba(X_test)[:, 1] if hasattr(model, 'predict_proba') else None + + if y_proba is not None: + y_pred = (y_proba >= threshold).astype(int) + else: + y_pred = model.predict(X_test) + y_proba = None + + metrics = self.__calculate_all_metrics(y_test, y_pred, y_proba) + + # Wrap in mean/std format to match overall_metrics structure + return { + k: { + 'mean': v, + 'median': v, + 'std': 0.0, + 'min': v, + 'max': v, + } for k, v in metrics.items() if v != "N/A" + } + + def __recalculate_metrics_with_threshold_to_notebook(self): + self.CodeHandler.add_line("code", "def recalculate_metrics_with_threshold(model, X_test, y_test):", indent=0) + self.CodeHandler.add_line("code", "threshold = getattr(model, 'probability_threshold', getattr(model, 'threshold', 0.5))", indent=1) + self.CodeHandler.add_line("code", "y_proba = model.predict_proba(X_test)[:, 1] if hasattr(model, 'predict_proba') else None", indent=1) + self.CodeHandler.add_line("code", "if y_proba is not None:", indent=1) + self.CodeHandler.add_line("code", "y_pred = (y_proba >= threshold).astype(int)", indent=2) + self.CodeHandler.add_line("code", "else:", indent=1) + self.CodeHandler.add_line("code", "y_pred = model.predict(X_test)", indent=2) + self.CodeHandler.add_line("code", "y_proba = None if y_proba is None else y_proba", indent=2) + self.CodeHandler.add_line("code", "metrics = calculate_all_metrics(y_test, y_pred, y_proba)", indent=1) + self.CodeHandler.add_line("code", "return {k: {'mean': v, 'median': v, 'std': 0.0, 'min': v, 'max': v} for k, v in metrics.items() if v != \"N/A\"}", indent=1) + def __custom_train_and_evaluate( self, pycaret_exp, folds: list, X_processed: pd.DataFrame, y_processed: pd.Series, - random_state=42, finalize=False, final_setup_kwargs: dict = {}, **ml_settings ) -> None: - """ - Custom function to train and evaluate models using PyCaret's create_model and tune_model functions. - - Args: - pycaret_exp (object): The PyCaret experiment object. - model_id (str): The model ID to train and evaluate. - folds (list): List of fold data for cross-validation. - X_processed (pd.DataFrame): Processed feature data. - y_processed (pd.Series): Processed target data. - random_state (int): Random state for reproducibility. - ml_settings (dict): Additional settings for model training and evaluation. - - Returns: - None - """ + if folds is None: raise ValueError("Folds should not be None. Check the iteration data.") + # Functions required for code generation + self.CodeHandler.add_import("import copy") + self.CodeHandler.add_import("from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix, matthews_corrcoef") + self.CodeHandler.add_line("md", "Utility functions for metric calculations") + self.specificity_to_notebook() + self.npv_to_notebook() + self.__calculate_all_metrics_to_notebook() + self.__calculate_overall_metrics_to_notebook() + if self.optimize_threshold: + self.__recalculate_metrics_with_threshold_to_notebook() + # Initialization trained_models = [] fold_performances = [] optimization_metric = 'Accuracy' - - # Store all metrics for each fold all_fold_metrics = {} - # Update code handler with parameters + self.CodeHandler.add_line("md", "Training and evaluating models for each fold") self.CodeHandler.add_line("code", "\n# Initializing model training and evaluation") self.CodeHandler.add_line("code", "trained_models = []") self.CodeHandler.add_line("code", "fold_performances = []") self.CodeHandler.add_line("code", f"optimization_metric = '{optimization_metric}'") + self.CodeHandler.add_line("code", "all_fold_metrics = {}") - # Iterate through each fold and train the model for fold_data in folds: fold_num = fold_data['fold'] train_indices = fold_data['train_indices'] test_indices = fold_data['test_indices'] - # Fold data extraction try: X_train_fold = X_processed.iloc[train_indices] y_train_fold = y_processed.iloc[train_indices] @@ -226,10 +293,9 @@ def __custom_train_and_evaluate( except IndexError as e: raise ValueError(f"Index error during fold data extraction on fold {fold_num}: {e}") - # Model Instantiation + # setup a unique experiment for each fold if 'log_experiment' in list(final_setup_kwargs.keys()): del final_setup_kwargs['log_experiment'] - # Create a new Pycaret experiment for each fold if needed fold_exp = copy.deepcopy(pycaret_exp) fold_exp.setup( data=pd.concat([X_train_fold, y_train_fold], axis=1), @@ -238,24 +304,19 @@ def __custom_train_and_evaluate( index=False, **final_setup_kwargs ) - # Use PyCaret's create_model instead of manual instantiation model = fold_exp.create_model(verbose=False, **ml_settings) - - # For logging purposes + + # This is only for logging purposes _ = pycaret_exp.create_model(verbose=False, **ml_settings) - # Model Tuning if self.isTuningEnabled: - # Check if optimization metric is set if 'optimize' in self.settingsTuning and self.settingsTuning['optimize']: optimization_metric = self.settingsTuning['optimize'] - # Check if a custom grid is provided if self.useTuningGrid and self.model_id in list(self.config_json['data']['internal'].keys()) and 'custom_grid' in list(self.config_json['data']['internal'][self.model_id].keys()): raw_grid = self.config_json['data']['internal'][self.model_id]['custom_grid'] self.settingsTuning['custom_grid'] = sanitize_custom_grid(raw_grid) - # Convert hidden_layer_sizes if it is a string if "hidden_layer_sizes" in self.settingsTuning['custom_grid']: val = self.settingsTuning['custom_grid']["hidden_layer_sizes"] if isinstance(val, list): @@ -271,43 +332,36 @@ def __custom_train_and_evaluate( except Exception as e: raise ValueError(f"Invalid tuple format for hidden_layer_sizes: {val}") from e - # Tune the model model = fold_exp.tune_model(model, **self.settingsTuning) - - # Log models params custom_logger = pycaret_exp.get_config('logging_param').loggers[0] custom_logger.log_params(model.get_params()) - # Model Ensembling try: if self.ensembleEnabled: model = fold_exp.ensemble_model(model, **self.settingsEnsemble) except Exception as e: raise ValueError(f"Failed to ensemble model on fold {fold_num}. Error: {e}") - # Model Calibration try: if self.calibrateEnabled: model = fold_exp.calibrate_model(model, **self.settingsCalibrate) except Exception as e: raise ValueError(f"Failed to calibrate model on fold {fold_num}. Error: {e}") - # Testing on the test set for this fold - # Make predictions on the test set - y_pred = model.predict(X_test_fold) y_proba = model.predict_proba(X_test_fold)[:, 1] if hasattr(model, 'predict_proba') else None - # Calculate all metrics manually + if y_proba is not None: + threshold = getattr(model, 'probability_threshold', getattr(model, 'threshold', 0.5)) + y_pred = (y_proba >= threshold).astype(int) + else: + y_pred = model.predict(X_test_fold) + fold_metric_results = self.__calculate_all_metrics(y_test_fold, y_pred, y_proba) - - # Store metrics for this fold all_fold_metrics[fold_num] = fold_metric_results - # Get predictions for probability-based metrics if available if optimization_metric.lower() == 'auc' and hasattr(model, 'predict_proba'): y_pred = model.predict_proba(X_test_fold)[:, 1] - # Store the model and its performance fold_score = check_metric(y_test_fold, pd.Series(y_pred), metric=optimization_metric) fold_performances.append({ 'fold': fold_num, @@ -316,11 +370,13 @@ def __custom_train_and_evaluate( 'score': fold_score, 'test_indices': test_indices }) - - # Store Results for the fold trained_models.append(model) - # Update code handler with training loop + overall_metrics, log_metrics = self.__calculate_overall_metrics(all_fold_metrics) + custom_logger = pycaret_exp.get_config('logging_param').loggers[0] + custom_logger.log_metrics(log_metrics) + + # Code generation self.CodeHandler.add_line("code", f"\n# Training and evaluating models for {len(folds)} folds") self.CodeHandler.add_line("code", f"for fold_data in folds:") self.CodeHandler.add_line("code", f"fold_num = fold_data['fold']", indent=1) @@ -330,84 +386,91 @@ def __custom_train_and_evaluate( self.CodeHandler.add_line("code", f"y_train_fold = y_processed.iloc[train_indices]", indent=1) self.CodeHandler.add_line("code", f"X_test_fold = X_processed.iloc[test_indices]", indent=1) self.CodeHandler.add_line("code", f"y_test_fold = y_processed.iloc[test_indices]", indent=1) + self.CodeHandler.add_line("code", f"# Setup PyCaret experiment for the fold", indent=1) + self.CodeHandler.add_line("code", f"fold_exp = copy.deepcopy(pycaret_exp)", indent=1) + self.CodeHandler.add_line("code", f"fold_exp.setup(data=pd.concat([X_train_fold, y_train_fold], axis=1), test_data=pd.concat([X_test_fold, y_test_fold]," \ + f" axis=1), log_experiment=False, index=False, **{final_setup_kwargs})", indent=1) self.CodeHandler.add_line("code", f"# Create and fit model", indent=1) - self.CodeHandler.add_line("code", f"model = pycaret_exp.create_model(verbose=False, {self.CodeHandler.convert_dict_to_params(ml_settings)})", indent=1) - self.CodeHandler.add_line("code", f"if hasattr(model, 'random_state'):", indent=1) - self.CodeHandler.add_line("code", f"setattr(model, 'random_state', {random_state})", indent=2) - self.CodeHandler.add_line("code", f"model.fit(X_train_fold, y_train_fold)", indent=1) + self.CodeHandler.add_line("code", f"model = fold_exp.create_model(verbose=False, {self.CodeHandler.convert_dict_to_params(ml_settings)})", indent=1) + self.CodeHandler.add_line("code", f"# This is only for logging purposes", indent=1) + self.CodeHandler.add_line("code", f"_ = pycaret_exp.create_model(verbose=False, {self.CodeHandler.convert_dict_to_params(ml_settings)})", indent=1) + if self.isTuningEnabled: + self.CodeHandler.add_line("code", f"model = fold_exp.tune_model(model, {self.CodeHandler.convert_dict_to_params(self.settingsTuning)})", indent=1) + if self.ensembleEnabled: + self.CodeHandler.add_line("code", f"model = fold_exp.ensemble_model(model, {self.CodeHandler.convert_dict_to_params(self.settingsEnsemble)})", indent=1) + if self.calibrateEnabled: + self.CodeHandler.add_line("code", f"model = fold_exp.calibrate_model(model, {self.CodeHandler.convert_dict_to_params(self.settingsCalibrate)})", indent=1) self.CodeHandler.add_line("code", f"# Making predictions on the test set", indent=1) self.CodeHandler.add_line("code", f"y_pred = model.predict(X_test_fold)", indent=1) - self.CodeHandler.add_line("code", f"# Assess performance", indent=1) - self.CodeHandler.add_line("code", f"if optimization_metric.lower() == 'auc' and hasattr(model, 'predict_proba'):", indent=1) - self.CodeHandler.add_line("code", f"y_pred = model.predict_proba(X_test_fold)[:, 1]", indent=2) - self.CodeHandler.add_import("from pycaret.utils.generic import check_metric") - self.CodeHandler.add_line("code", f"fold_score = check_metric(y_test_fold, pd.Series(y_pred), metric=optimization_metric)", indent=1) - self.CodeHandler.add_line("code", f"fold_performances.append({{'fold': fold_num, 'model': model, 'score': fold_score, 'test_indices': test_indices}})", indent=1) + self.CodeHandler.add_line("code", f"# Calculate fold metrics", indent=1) + self.CodeHandler.add_line("code", f"y_proba = model.predict_proba(X_test_fold)[:, 1] if hasattr(model, 'predict_proba') else None", indent=1) + self.CodeHandler.add_line("code", f"fold_metric_results = calculate_all_metrics(y_test_fold, y_pred, y_proba)", indent=1) + self.CodeHandler.add_line("code", f"all_fold_metrics[fold_num] = fold_metric_results", indent=1) self.CodeHandler.add_line("code", f"trained_models.append(model)", indent=1) + self.CodeHandler.add_line("md", "Final Model Training and Overall Metrics Calculation") + self.CodeHandler.add_line("code", f"\n# Calculating overall metrics across folds") + self.CodeHandler.add_line("code", f"overall_metrics, log_metrics = calculate_overall_metrics(all_fold_metrics)", indent=0) + self.CodeHandler.add_line("code", f"print(f\"Overall Metrics: \", overall_metrics)", indent=0) - # Calculate overall metrics across all folds - overall_metrics, log_metrics = self.__calculate_overall_metrics(all_fold_metrics) - - # Manually log metrics - custom_logger = pycaret_exp.get_config('logging_param').loggers[0] - custom_logger.log_metrics(log_metrics) - - # Select the best model based on performance if fold_performances: - # Sort by score (higher is better) and select the best model - best_model = sorted(fold_performances, key=lambda x: x['score'], reverse=True)[0]['model'] - best_exp = sorted(fold_performances, key=lambda x: x['score'], reverse=True)[0]['experiment'] - - # Update code handler with best model selection - self.CodeHandler.add_line("code", "\n# Selecting the best model based on performance") - self.CodeHandler.add_line("code", f"best_model = sorted(fold_performances, key=lambda x: x['score'], reverse=True)[0]['model']") - - # Final evaluation on the entire dataset if needed try: - # Ensure the final model has the same random state - if hasattr(best_model, 'random_state'): - setattr(best_model, 'random_state', random_state) - - # Update code handler - self.CodeHandler.add_line("code", f"setattr(best_model, 'random_state', {random_state})") - - # Final fit on the entire dataset - best_model.fit(X_processed, y_processed) + # Final model: rebuild from the best config: + best_model = pycaret_exp.create_model(**ml_settings) + self.CodeHandler.add_line("code", f"best_model = pycaret_exp.create_model({self.CodeHandler.convert_dict_to_params(ml_settings)})", indent=0) - # Optimize model's threshold if enabled - if self.optimize_threshold: - # Do not optimize if data is multiclass - if len(pycaret_exp.get_config('y').unique()) != 2: - print("Skipping threshold optimization (multiclass not supported).") - - # Do not optimize if ensemble was applied - elif self.ensembleEnabled: - print("Skipping threshold optimization (ensemble not supported).") - - else: - best_model = best_exp.optimize_threshold(best_model, optimize=self.threshold_optimization_metric) - - # Update code handler with final fit - self.CodeHandler.add_line("code", f"best_model.fit(X_processed, y_processed)") + # Tuning if self.isTuningEnabled: + best_model = pycaret_exp.tune_model(best_model, **self.settingsTuning) self.CodeHandler.add_line("code", f"# Tuning model", indent=0) self.CodeHandler.add_line("code", f"best_model = pycaret_exp.tune_model(best_model, {self.CodeHandler.convert_dict_to_params(self.settingsTuning)})", indent=0) + + # Ensembling if self.ensembleEnabled: + best_model = pycaret_exp.ensemble_model(best_model, **self.settingsEnsemble) self.CodeHandler.add_line("code", f"# Ensembling model", indent=0) self.CodeHandler.add_line("code", f"best_model = pycaret_exp.ensemble_model(best_model, {self.CodeHandler.convert_dict_to_params(self.settingsEnsemble)})", indent=0) + + # Calibration if self.calibrateEnabled: + best_model = pycaret_exp.calibrate_model(best_model, **self.settingsCalibrate) self.CodeHandler.add_line("code", f"# Calibrating model", indent=0) self.CodeHandler.add_line("code", f"best_model = pycaret_exp.calibrate_model(best_model, {self.CodeHandler.convert_dict_to_params(self.settingsCalibrate)})", indent=0) + if self.optimize_threshold: - if len(pycaret_exp.get_config('y').unique()) == 2 and not self.ensembleEnabled: + if len(pycaret_exp.get_config('y').unique()) != 2: + print("Skipping threshold optimization (multiclass not supported).") + elif self.ensembleEnabled: + print("Skipping threshold optimization (ensemble not supported).") + else: + best_model = pycaret_exp.optimize_threshold(best_model, optimize=self.threshold_optimization_metric) + self.CodeHandler.add_line( + "md", + f"Optimized threshold: {getattr(best_model, 'probability_threshold', getattr(best_model, 'threshold', 'not found'))}" + ) + # recalculate metrics using the optimized threshold + X_test_final = pycaret_exp.get_config('X_test_transformed') + y_test_final = pycaret_exp.get_config('y_test_transformed') + overall_metrics = self.__recalculate_metrics_with_threshold(best_model, X_test_final, y_test_final) + log_metrics = {k: v['mean'] for k, v in overall_metrics.items()} + custom_logger.log_metrics(log_metrics) + + # Code generation self.CodeHandler.add_line("code", f"# Optimizing model threshold based on {self.threshold_optimization_metric}", indent=0) - self.CodeHandler.add_line("code", f"best_model = pycaret_exp.optimize_threshold(best_model, metric='{self.threshold_optimization_metric}')", indent=0) + self.CodeHandler.add_line( + "code", + f"best_model = pycaret_exp.optimize_threshold(best_model, optimize='{self.threshold_optimization_metric}')", + indent=0 + ) + self.CodeHandler.add_line("code", f"# Recalculating metrics with optimized threshold", indent=0) + self.CodeHandler.add_line("code", f"X_test_final = pycaret_exp.get_config('X_test_transformed')", indent=0) + self.CodeHandler.add_line("code", f"y_test_final = pycaret_exp.get_config('y_test_transformed')", indent=0) + self.CodeHandler.add_line("code", f"overall_metrics = recalculate_metrics_with_threshold(best_model, X_test_final, y_test_final)", indent=0) + self.CodeHandler.add_line("code", "print(\"Overall Metrics with optimized threshold: \", overall_metrics)", indent=0) - # Finalize the model if finalize: - best_model = best_exp.finalize_model(best_model) + best_model = pycaret_exp.finalize_model(best_model) + self.CodeHandler.add_line("code", f"best_model = pycaret_exp.finalize_model(best_model)", indent=0) - # Store the final model self.CodeHandler.add_line("code", f"trained_models = [best_model]") return {'model': best_model, 'overall_metrics': overall_metrics} except Exception as e: @@ -416,40 +479,23 @@ def __custom_train_and_evaluate( raise ValueError("No fold performances were recorded. Check the training process.") def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> None: - """ - Trains and evaluates models using PyCaret's create_model and tune_model functions based on user-defined splits. - - Args: - experiment (dict): The experiment dictionary containing the PyCaret experiment object. - settings (dict): The settings for the model training and evaluation. - **kwargs: Additional arguments including dataset and iteration_result. - Returns: - None - """ - - # Initialize variables final_setup_kwargs = kwargs.get("final_setup_kwargs", {}) iteration_data = kwargs["split_indices"] pycaret_exp = experiment['pycaret_exp'] - random_state = kwargs.get("random_state", 42) finalize = kwargs.get("finalize", False) overall_metrics = {} - # Setup models to train and evaluate try: if self.type != 'train_model': raise ValueError(f"Something went wrong, the type of the node is {self.type}, but it should be 'train_model'.") - # For train_model, we can use the model_id from the configuration settings.update(self.config_json['data']['estimator']['settings']) import ast - # Convert hidden_layer_sizes if it is a string if "hidden_layer_sizes" in settings: val = settings["hidden_layer_sizes"] if isinstance(val, str) and val.startswith("(") and val.endswith(")"): - print("Converting hidden_layer_sizes:", val) try: settings["hidden_layer_sizes"] = ast.literal_eval(val) except Exception as e: @@ -465,29 +511,23 @@ def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> split_type = iteration_data['type'] folds = iteration_data['folds'] - # Iterate through models to train and evaluate trained_model = None if split_type == "cross_validation": - # Check if estimator is already set in settings if 'estimator' in settings: if model_to_evaluate != settings['estimator']: - raise ValueError(f"Model ID {model_to_evaluate} does not match the estimator in settings. Please check your configuration.") + raise ValueError(f"Model ID {model_to_evaluate} does not match the estimator in settings.") else: settings['estimator'] = model_to_evaluate self.CodeHandler.add_line("code", f"# Training model: {model_to_evaluate}") - # Use PyCaret's create_model instead of manual instantiation trained_model = pycaret_exp.create_model(**settings) self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.create_model({self.CodeHandler.convert_dict_to_params(settings)})]") - # tune model if enabled if self.isTuningEnabled: - # Check if a custom grid is provided if self.useTuningGrid and self.model_id in list(self.config_json['data']['internal'].keys()) and 'custom_grid' in list(self.config_json['data']['internal'][self.model_id].keys()): raw_grid = self.config_json['data']['internal'][self.model_id]['custom_grid'] self.settingsTuning['custom_grid'] = sanitize_custom_grid(raw_grid) - # Convert hidden_layer_sizes if it is a string if "hidden_layer_sizes" in self.settingsTuning['custom_grid']: val = self.settingsTuning['custom_grid']["hidden_layer_sizes"] if isinstance(val, list): @@ -503,7 +543,6 @@ def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> except Exception as e: raise ValueError(f"Invalid tuple format for hidden_layer_sizes: {val}") from e - # Tune the model try: trained_model = pycaret_exp.tune_model(trained_model, **self.settingsTuning) except Exception as e: @@ -523,28 +562,19 @@ def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> f"trained_models = [pycaret_exp.tune_model('{self.config_json['data']['estimator']['type']}', optimize='{self.settingsTuning.get('optimize','Accuracy')}')]" ) - # Ensemble model if enabled if self.ensembleEnabled: trained_model = pycaret_exp.ensemble_model(trained_model, **self.settingsEnsemble) self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.ensemble_model(trained_models[0], {self.CodeHandler.convert_dict_to_params(self.settingsEnsemble)})]") - # Calibrate model if enabled if self.calibrateEnabled: trained_model = pycaret_exp.calibrate_model(trained_model, **self.settingsCalibrate) self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.calibrate_model(trained_models[0], {self.CodeHandler.convert_dict_to_params(self.settingsCalibrate)})]") - # Optimize model's threshold if enabled - # Optimize threshold only for binary, non-calibrated, non-ensemble models if self.optimize_threshold: - - # Do not optimize if data is multiclass if len(pycaret_exp.get_config('y').unique()) != 2: print("Skipping threshold optimization (multiclass not supported).") - - # Do not optimize if ensemble was applied elif self.ensembleEnabled: print("Skipping threshold optimization (ensemble not supported).") - else: trained_model = pycaret_exp.optimize_threshold( trained_model, @@ -552,15 +582,20 @@ def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> ) self.CodeHandler.add_line( "code", - f"trained_models = [pycaret_exp.optimize_threshold(trained_models[0], metric='{self.threshold_optimization_metric}')]" + f"trained_models = [pycaret_exp.optimize_threshold(trained_models[0], optimize='{self.threshold_optimization_metric}')]" + ) + self.CodeHandler.add_line( + "md", + f"Optimized threshold: {getattr(trained_model, 'probability_threshold', getattr(trained_model, 'threshold', 'not found'))}" ) if finalize: trained_model = pycaret_exp.finalize_model(trained_model) + self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.finalize_model(trained_models[0])]") - # Get final metrics dict + # Get final metrics from pycaret_exp.pull() (pre-threshold metrics) final_metrics = pycaret_exp.pull().to_dict(orient='records') - for fold in final_metrics[:-2]: # Exclude 'Mean' and 'Std' rows + for fold in final_metrics[:-2]: for metric in list(fold.keys()): if metric not in list(overall_metrics.keys()): overall_metrics[metric] = [] @@ -574,24 +609,34 @@ def __handle_splitted_data(self, experiment: dict, settings: dict, **kwargs) -> 'min': round(float(np.min(overall_metrics[metric])), 3), 'max': round(float(np.max(overall_metrics[metric])), 3), } + + # if threshold was optimized, recalculate metrics using the optimized threshold + # __recalculate_metrics_with_threshold now reads probability_threshold correctly + if self.optimize_threshold and len(pycaret_exp.get_config('y').unique()) == 2 and not self.ensembleEnabled: + X_test_final = pycaret_exp.get_config('X_test_transformed') + y_test_final = pycaret_exp.get_config('y_test_transformed') + overall_metrics = self.__recalculate_metrics_with_threshold(trained_model, X_test_final, y_test_final) + + # Code generation + self.CodeHandler.add_line("code", f"# Recalculating overall metrics with optimized threshold", indent=0) + self.CodeHandler.add_line("code", f"X_test_final = pycaret_exp.get_config('X_test_transformed')", indent=0) + self.CodeHandler.add_line("code", f"y_test_final = pycaret_exp.get_config('y_test_transformed')", indent=0) + self.CodeHandler.add_line("code", f"overall_metrics = recalculate_metrics_with_threshold(trained_models[0], X_test_final, y_test_final)", indent=0) + return {'model': trained_model, 'overall_metrics': overall_metrics} else: - # Retrieve processed data from PyCaret X_processed = pycaret_exp.get_config('X_transformed') y_processed = pycaret_exp.get_config('y_transformed') - # Update code handler self.CodeHandler.add_line("code", "# Retrieve processed data from PyCaret") self.CodeHandler.add_line("code", f"X_processed = pycaret_exp.get_config('X_transformed')") self.CodeHandler.add_line("code", f"y_processed = pycaret_exp.get_config('y_transformed')") - # Custom training and evaluation function results = self.__custom_train_and_evaluate( pycaret_exp, folds, X_processed, y_processed, - random_state, finalize, final_setup_kwargs, **settings @@ -607,6 +652,14 @@ def specificity(self, y_true, y_pred): return tn / (tn + fp) if (tn + fp) > 0 else 0 return 0 + def specificity_to_notebook(self): + self.CodeHandler.add_line("code", "def specificity(y_true, y_pred):", indent=0) + self.CodeHandler.add_line("code", "cm = confusion_matrix(y_true, y_pred)", indent=1) + self.CodeHandler.add_line("code", "if cm.shape == (2, 2):", indent=1) + self.CodeHandler.add_line("code", "tn, fp, fn, tp = cm.ravel()", indent=2) + self.CodeHandler.add_line("code", "return tn / (tn + fp) if (tn + fp) > 0 else 0", indent=2) + self.CodeHandler.add_line("code", "return 0", indent=1) + def balanced_accuracy(self, y_true, y_pred): """Balanced Accuracy""" sensitivity = recall_score(y_true, y_pred, zero_division=0) @@ -621,25 +674,30 @@ def npv(self, y_true, y_pred): return tn / (tn + fn) if (tn + fn) > 0 else 0 return 0 - def youden_index(self, y_true, y_proba): - """Youden's J statistic""" - y_pred = (y_proba >= 0.5).astype(int) - sensitivity = recall_score(y_true, y_pred, zero_division=0) - specificity = self.specificity(y_true, y_pred) - return sensitivity + specificity - 1 - + def npv_to_notebook(self): + self.CodeHandler.add_line("code", "def npv(y_true, y_pred):", indent=0) + self.CodeHandler.add_line("code", "cm = confusion_matrix(y_true, y_pred)", indent=1) + self.CodeHandler.add_line("code", "if cm.shape == (2, 2):", indent=1) + self.CodeHandler.add_line("code", "tn, fp, fn, tp = cm.ravel()", indent=2) + self.CodeHandler.add_line("code", "return tn / (tn + fn) if (tn + fn) > 0 else 0", indent=2) + self.CodeHandler.add_line("code", "return 0", indent=1) + + def youden_index(self, y_true, y_pred): + """ + Youden's J statistic. + PyCaret calls score_func(y_true, y_pred_binary) at each threshold step + during optimize_threshold. Using roc_curve here was incorrect. + """ + return recall_score(y_true, y_pred, zero_division=0) + self.specificity(y_true, y_pred) - 1 + def mcc(self, y_true, y_pred): """Matthews Correlation Coefficient""" return matthews_corrcoef(y_true, y_pred) def _execute(self, experiment: dict = None, **kwargs) -> json: - """ - This function is used to execute the node. - """ print(Fore.BLUE + "=== fit === " + Fore.YELLOW + f"({self.username})" + Fore.RESET) print(Fore.CYAN + f"Using {self.type}" + Fore.RESET) - # Add custom metrics to PyCaret try: experiment['pycaret_exp'].add_metric(id='specificity', name='Specificity', score_func=self.specificity) except Exception as e: @@ -666,7 +724,6 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: else: self.CodeHandler.add_line("md", f"##### *Model ID: {self.username}*") - # Initialization trained_models = None trained_models_json = {} settings = copy.deepcopy(self.settings) @@ -675,14 +732,12 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: splitted = kwargs.get("splitted", None) finalize = kwargs.get("finalize", False) - # If data is splitted, we need to train and evaluate the model using the custom function that handles splitted data if splitted: results = self.__handle_splitted_data(experiment, settings, **kwargs) trained_models = [results['model']] all_metrics = results['overall_metrics'] trained_models_json['overall_metrics'] = all_metrics - # Experimental scene: comparing multiple models with compare_models elif self.type == 'compare_models': models = experiment['pycaret_exp'].compare_models(**settings) self.CodeHandler.add_line("code", f"trained_models = pycaret_exp.compare_models({self.CodeHandler.convert_dict_to_params(settings)})") @@ -690,16 +745,13 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: trained_models = models else: trained_models = [models] - self.CodeHandler.add_line("code", "# pycaret_exp.compare_models() returns a single model, but we want a list of models") self.CodeHandler.add_line("code", "trained_models = [trained_models]") - # Regular scene: training a single model with create_model (with other optional steps like tuning, ensembling, calibrating, threshold optimization) elif self.type == 'train_model': settings.update(self.config_json['data']['estimator']['settings']) import ast - # Convert hidden_layer_sizes if it is a string if "hidden_layer_sizes" in settings: val = settings["hidden_layer_sizes"] if isinstance(val, str) and val.startswith("(") and val.endswith(")"): @@ -712,12 +764,10 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: trained_models = [experiment['pycaret_exp'].create_model(**settings)] self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.create_model({self.CodeHandler.convert_dict_to_params(settings)})]") if self.isTuningEnabled: - # Check if a custom grid is provided if self.useTuningGrid and self.model_id in list(self.config_json['data']['internal'].keys()) and 'custom_grid' in list(self.config_json['data']['internal'][self.model_id].keys()): raw_grid = self.config_json['data']['internal'][self.model_id]['custom_grid'] self.settingsTuning['custom_grid'] = sanitize_custom_grid(raw_grid) - # Convert hidden_layer_sizes if it is a string if "hidden_layer_sizes" in self.settingsTuning['custom_grid']: val = self.settingsTuning['custom_grid']["hidden_layer_sizes"] if isinstance(val, list): @@ -733,7 +783,6 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: except Exception as e: raise ValueError(f"Invalid tuple format for hidden_layer_sizes: {val}") from e - # Tune the model trained_models = [experiment['pycaret_exp'].tune_model(trained_models[0], **self.settingsTuning)] self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.tune_model(trained_models[0], {self.CodeHandler.convert_dict_to_params(self.settingsTuning)})]") @@ -746,22 +795,29 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.calibrate_model(trained_models[0], {self.CodeHandler.convert_dict_to_params(self.settingsCalibrate)})]") if self.optimize_threshold: - # Do not optimize if data is multiclass if len(experiment['pycaret_exp'].get_config('y').unique()) != 2: print("Skipping threshold optimization (multiclass not supported).") - - # Do not optimize if ensemble was applied elif self.ensembleEnabled: print("Skipping threshold optimization (ensemble not supported).") - else: trained_models = [experiment['pycaret_exp'].optimize_threshold(trained_models[0], optimize=self.threshold_optimization_metric)] - self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.optimize_threshold(trained_models[0], metric='{self.threshold_optimization_metric}')]") + self.CodeHandler.add_line("code", f"trained_models = [pycaret_exp.optimize_threshold(trained_models[0], optimize='{self.threshold_optimization_metric}')]") + self.CodeHandler.add_line( + "md", + f"Optimized threshold: {getattr(trained_models[0], 'probability_threshold', getattr(trained_models[0], 'threshold', 'not found'))}" + ) + # recalculate metrics using the optimized threshold + X_test_final = experiment['pycaret_exp'].get_config('X_test_transformed') + y_test_final = experiment['pycaret_exp'].get_config('y_test_transformed') + trained_models_json['overall_metrics'] = self.__recalculate_metrics_with_threshold( + trained_models[0], X_test_final, y_test_final + ) if finalize: trained_models = [experiment['pycaret_exp'].finalize_model(model) for model in trained_models] else: raise ValueError(f"Unsupported type: {self.type}. Expected 'compare_models' or 'train_model'.") + trained_models_copy = trained_models.copy() settings_for_next = copy.deepcopy(settings) settings_for_next['fct_type'] = self.type @@ -774,30 +830,3 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: if isinstance(value, np.ndarray): trained_models_json[model_copy.__class__.__name__][key] = value.tolist() return trained_models_json - - def set_model(self, model_id: str) -> None: - """ - This function sets the model configuration for the current node based on the given model_id. - """ - try: - model_obj = self.global_config_json['nodes'][model_id] - self.config_json['data']['estimator'] = { - "type": model_obj['data']['selection'], - "settings": model_obj['data']['settings'] - } - if self.isTuningEnabled: - self.config_json['data']['internal']['settingsTuning'] = model_obj['data']['internal'].get('settingsTuning', {}) - except KeyError as e: - print(f"ERROR: Failed to set model {model_id}. Missing key: {e}") - raise ValueError(f"Model configuration for {model_id} not found in the global config.") - except Exception as e: - print(f"ERROR: An error occurred while setting the model: {e}") - raise ValueError(f"An error occurred while setting model {model_id}.") - - def set_model(self, model_id: str) -> None: - # self.model_id = model_id - model_obj = self.global_config_json['nodes'][model_id] - self.config_json['data']['estimator'] = { - "type": model_obj['data']['selection'], - "settings": model_obj['data']['settings'] - } \ No newline at end of file diff --git a/pythonCode/med_libs/MEDml/nodes/ModelIO.py b/pythonCode/med_libs/MEDml/nodes/ModelIO.py index 8f1345e7..68c7436b 100644 --- a/pythonCode/med_libs/MEDml/nodes/ModelIO.py +++ b/pythonCode/med_libs/MEDml/nodes/ModelIO.py @@ -58,20 +58,24 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: if dir(fitted_model).__contains__('feature_names_in_'): model_features = fitted_model.__getattribute__('feature_names_in_') elif dir(fitted_model).__contains__('feature_name_') and model_features is None: - model_features = fitted_model.__getattribute__('feature_name_') + model_features = fitted_model.__getattribute__('feature_names_in_') + elif dir(fitted_model).__contains__('classifier_') and dir(fitted_model.classifier_).__contains__('feature_names_in_'): + model_features = fitted_model.classifier_.feature_names_in_ else: - model_features= fitted_model.__getattr__('feature_names_in_') - + raise ValueError(f"Could not find model features. Model attributes : {dir(fitted_model)}, model type: {type(fitted_model)}") if model_features is None: - raise ValueError(f"Could not find model features. Model attributes : {dir(fitted_model)}, model type: {type(fitted_model)}, model features: {model_features}") - + raise ValueError(f"Could not find model features. Model attributes : {dir(fitted_model)}, model type: {type(fitted_model)}") + model_features = list(model_features) + # Model's algorithm + model_algorithm = fitted_model.__class__.__name__ + # Model's name if 'model_name' in settings.keys() and settings['model_name']: model_name = settings['model_name'] else: - model_name = fitted_model.__class__.__name__ + model_name = model_algorithm # Path save model (if too big for MongoDB) if 'pathSave' in settings.keys() and settings['pathSave']: @@ -156,7 +160,8 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: "columns": model_features, "target": self.global_config_json["target_column"], "steps": self.global_config_json["steps"], - "ml_type": self.global_config_json["MLType"] + "ml_type": self.global_config_json["MLType"], + "algorithm": model_algorithm } if 'selectedTags' in self.global_config_json: to_write['selectedTags'] = self.global_config_json['selectedTags'] diff --git a/pythonCode/med_libs/MEDml/nodes/NodeObj.py b/pythonCode/med_libs/MEDml/nodes/NodeObj.py index 184edd64..417d38a5 100644 --- a/pythonCode/med_libs/MEDml/nodes/NodeObj.py +++ b/pythonCode/med_libs/MEDml/nodes/NodeObj.py @@ -201,6 +201,16 @@ def __init__(self, base_code: List[dict] = []) -> None: self.code = base_code self.imports = [] + def __check_if_import_exists(self, import_name: str) -> bool: + """ + Checks if an import already exists in the code + Args: + import_name: name of the import to check + Returns: + True if the import already exists, False otherwise + """ + return import_name in [imp['content'] for imp in self.imports] + def add_import(self, import_name: str): """ Adds an import to the code @@ -210,8 +220,9 @@ def add_import(self, import_name: str): Returns: None """ - self.imports.append( - {"type": "code", "content": import_name, "indent": 0}) + if not self.__check_if_import_exists(import_name): + self.imports.append( + {"type": "code", "content": import_name, "indent": 0}) def add_line(self, line_type: str, line: str, indent: int = 0): """ diff --git a/pythonCode/med_libs/MEDml/nodes/Split.py b/pythonCode/med_libs/MEDml/nodes/Split.py index 85ff9972..27efc7d0 100644 --- a/pythonCode/med_libs/MEDml/nodes/Split.py +++ b/pythonCode/med_libs/MEDml/nodes/Split.py @@ -269,7 +269,7 @@ def _execute(self, experiment: dict = None, **kwargs) -> json: splitter = StratifiedKFold(n_splits=cv_folds, shuffle=True, random_state=random_state) self.CodeHandler.add_line("code", f"splitter = StratifiedKFold(n_splits={cv_folds}, shuffle=True, random_state={random_state})") fold_iter = splitter.split(np.zeros(n_samples), y) - self.CodeHandler.add_line("code", f"fold_iter = splitter.split(np.zeros({n_samples}), y)") + self.CodeHandler.add_line("code", f"fold_iter = splitter.split(np.zeros(len(dataset)), y)") else: splitter = KFold(n_splits=cv_folds, shuffle=True, random_state=random_state) self.CodeHandler.add_line("code", f"splitter = KFold(n_splits={cv_folds}, shuffle=True, random_state={random_state})") diff --git a/pythonCode/med_libs/MEDml/utils/settings_generator/requirements.txt b/pythonCode/med_libs/MEDml/utils/settings_generator/requirements.txt index 7c59e7bf..d34e01d5 100644 Binary files a/pythonCode/med_libs/MEDml/utils/settings_generator/requirements.txt and b/pythonCode/med_libs/MEDml/utils/settings_generator/requirements.txt differ diff --git a/pythonCode/modules/evaluation/predict_test.py b/pythonCode/modules/evaluation/predict_test.py index 5893da18..f65ece5e 100644 --- a/pythonCode/modules/evaluation/predict_test.py +++ b/pythonCode/modules/evaluation/predict_test.py @@ -108,6 +108,10 @@ def _custom_process(self, json_config: dict) -> dict: self.set_progress(label="Predicting...", now=50) pred_unseen = predict_model(model, data=dataset) self.set_progress(now=70) + + # rename column "prediction_score" to "confidence_score" if it exists + if "prediction_score" in pred_unseen.columns: + pred_unseen = pred_unseen.rename(columns={"prediction_score": "confidence_score"}) # Save predictions prediction_object = MEDDataObject( diff --git a/pythonCode/modules/superset/SupersetEnvManager.py b/pythonCode/modules/superset/SupersetEnvManager.py index d3cdc85f..31c693a7 100644 --- a/pythonCode/modules/superset/SupersetEnvManager.py +++ b/pythonCode/modules/superset/SupersetEnvManager.py @@ -41,7 +41,21 @@ def _get_build_env(self): """ env = os.environ.copy() - if sys.platform != "win32": + if sys.platform == "darwin": + # Force the compiler to use the system SDK and headers + cc = shutil.which("clang") or shutil.which("gcc") + cxx = shutil.which("clang++") or shutil.which("g++") + if cc: + env["CC"] = cc + if cxx: + env["CXX"] = cxx + + # Critical for 'cryptography' and 'python-geohash' compilation + env["LDFLAGS"] = "-L/usr/local/opt/openssl/lib -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib" + env["CPPFLAGS"] = "-I/usr/local/opt/openssl/include -I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include" + # Prevents error: 'implicit declaration of function' during geohash build + env["CFLAGS"] = "-Wno-error=implicit-function-declaration" + elif sys.platform != "win32": cc = shutil.which("gcc") or shutil.which("cc") cxx = shutil.which("g++") or shutil.which("c++") if cc: diff --git a/pythonEnv/requirements.txt b/pythonEnv/requirements.txt index fc3cc7af..af2c490a 100644 --- a/pythonEnv/requirements.txt +++ b/pythonEnv/requirements.txt @@ -243,7 +243,6 @@ safetensors==0.4.0 schemdraw==0.15 scikit-base==0.5.2 scikit-image==0.19.3 -scikit-learn==1.2.0 scikit-plot==0.3.7 scipy==1.10.1 seaborn==0.12.2 diff --git a/renderer/components/flow/codeEditor.jsx b/renderer/components/flow/codeEditor.jsx index ec5c097a..79aaa0e2 100644 --- a/renderer/components/flow/codeEditor.jsx +++ b/renderer/components/flow/codeEditor.jsx @@ -120,6 +120,7 @@ const CodeEditor = ({id, path, updateSavedCode}) => { const [content, setContent] = useState("Loading...") const [mode, setMode] = useState("text") const [error, setError] = useState("") + const [stackTrace, setStackTrace] = useState("") const [loading, setLoading] = useState(true) const [loadingSave, setLoadingSave] = useState(false) const { globalData } = useContext(DataContext) @@ -207,9 +208,17 @@ const CodeEditor = ({id, path, updateSavedCode}) => { (response) => { setLoading(false) console.log("Response from backend:", response) - if (response.error){ - setError(response.error) - toast.error("Error opening file: " + response.error) + if (response.error) { + if (typeof response.error === "object") { + setError(response.error.message || "An error occurred while loading the file.") + toast.error("Error loading file: " + (response.error.message || "Unknown error")) + if (response.error.stack_trace) { + setStackTrace(response.error.stack_trace) + } + } else if (typeof response.error === "string") { + setError(response.error) + toast.error("Error opening file: " + response.error) + } } else { setContent(response.content) @@ -253,6 +262,11 @@ const CodeEditor = ({id, path, updateSavedCode}) => {
{error}
+ {stackTrace && ( +
+              {stackTrace}
+            
+ )}
) : ( diff --git a/renderer/components/learning/nodesTypes/datasetNode.jsx b/renderer/components/learning/nodesTypes/datasetNode.jsx index 32d16c43..cab394a4 100644 --- a/renderer/components/learning/nodesTypes/datasetNode.jsx +++ b/renderer/components/learning/nodesTypes/datasetNode.jsx @@ -357,26 +357,34 @@ const DatasetNode = ({ id, data }) => { return ( <> -
- Default inverse = N(negative) / N(positive). We recommand recalibrating through hyperparameters if this fraction exceeds 3. -

- Used for: - - Interpretation + target=".default-inverse-tooltip" + position="right" +> +
+ Class imbalance — general guideline +

+ + Default inverse = N(negative) / N(positive). + We recommend adjusting model settings if this fraction exceeds 3. + +

+ + Used for: +
    +
  • class_weight (Random Forest)
  • +
  • scale_pos_weight (XGBoost)
  • +
  • Calibration & metric selection
  • +
+ + Interpretation:
  • ≤ 3 → Balanced
  • 3 – 6 → Imbalanced
  • > 6 → Highly imbalanced
-
- +
+
+ { key={id} id={id} data={data} - color="#EAD196" + color="#cea037" setupParam={data.setupParam} nodeLink="/documentation/split" defaultSettings={ diff --git a/renderer/components/learning/nodesTypes/trainModelNode.jsx b/renderer/components/learning/nodesTypes/trainModelNode.jsx index 5f06a804..a5c3ba05 100644 --- a/renderer/components/learning/nodesTypes/trainModelNode.jsx +++ b/renderer/components/learning/nodesTypes/trainModelNode.jsx @@ -30,11 +30,11 @@ const TrainModelNode = ({ id, data }) => { const [modalShowTuning, setModalShowTuning] = useState(false) const { updateNode } = useContext(FlowFunctionsContext) const [IntegrateTuning, setIntegrateTuning] = useState(data.internal.isTuningEnabled ?? false) - const [optimizeThresh, setOptimizeThresh] = useState(data.internal.isOptimizeThreshold ?? false) - const [ensembleEnabled, setEnsembleEnabled] = useState(data.internal.settings.isEnsembleEnabled ?? false) - const [calibrateEnabled, setCalibrateEnabled] = useState(data.internal.settings.isCalibrateEnabled ?? false) + const [optimizeThresh, setOptimizeThresh] = useState(data.internal.optimizeThreshold ?? false) + const [ensembleEnabled, setEnsembleEnabled] = useState(data.internal.ensembleEnabled ?? false) + const [calibrateEnabled, setCalibrateEnabled] = useState(data.internal.calibrateEnabled ?? false) - // Check if isTuningEnabled exists in data.internal, if not initialize it + // Check if default settings exists in data.internal, if not initialize it useEffect(() => { let hasUpdates = false @@ -58,8 +58,9 @@ const TrainModelNode = ({ id, data }) => { const defaults = { isTuningEnabled: false, useTuningGrid: false, - isEnsembleEnabled: false, - isCalibrateEnabled: false, + ensembleEnabled: false, + calibrateEnabled: false, + optimizeThreshold: false, settingsEnsembling: {}, settingsCalibration: {} } @@ -185,21 +186,6 @@ const TrainModelNode = ({ id, data }) => { }) } - /** - * - * @param {Object} e the event of the checkbox - * @description - * This function is used to handle the checkbox for enabling the tuning - */ - const handleIntegration = (e) => { - setIntegrateTuning(e.value) - data.internal.isTuningEnabled = e.value - updateNode({ - id: id, - updatedData: data.internal - }) - } - return ( <> {/* build on top of the Node component */} diff --git a/renderer/components/learning/results/node/modelsResults.jsx b/renderer/components/learning/results/node/modelsResults.jsx index baad9f2e..40bc4c1b 100644 --- a/renderer/components/learning/results/node/modelsResults.jsx +++ b/renderer/components/learning/results/node/modelsResults.jsx @@ -47,14 +47,23 @@ const ModelsResults = ({ selectedResults }) => { useEffect(() => { let allModelsData = [] if (models.length > 0) { - models.forEach((model) => { - if (!model.metrics) return - let modifiedRow = { ...model.metrics } - modifiedRow["Parameters"] = model.params - modifiedRow["OverallMetrics"] = selectedResults?.data?.overall_metrics - modifiedRow = Object.assign({ Name: model.name }, modifiedRow) - allModelsData.push(modifiedRow) - }) + models.forEach((model) => { + if (!model.metrics) return + let modifiedRow = { ...model.metrics } + + // FIX: if overall_metrics exists, use these new metrics (optimal threshold) + const overallMetrics = selectedResults?.data?.overall_metrics + if (overallMetrics) { + Object.keys(overallMetrics).forEach((key) => { + modifiedRow[key] = overallMetrics[key].mean + }) + } + + modifiedRow["Parameters"] = model.params + modifiedRow["OverallMetrics"] = overallMetrics + modifiedRow = Object.assign({ Name: model.name }, modifiedRow) + allModelsData.push(modifiedRow) + }) } allModelsData.length > 0 && setAllModelsData(allModelsData) }, [models]) diff --git a/renderer/components/learning/results/utilities/parameters.jsx b/renderer/components/learning/results/utilities/parameters.jsx index b84c65ab..7d99d51f 100644 --- a/renderer/components/learning/results/utilities/parameters.jsx +++ b/renderer/components/learning/results/utilities/parameters.jsx @@ -15,19 +15,45 @@ import { Column } from "primereact/column" const Parameters = ({ params, tableProps, columnNames }) => { const [data, setData] = useState([]) const [selectedRows, setSelectedRows] = useState([]) + + const isEmptyOrNull = (value) => { + // Check specifically for null or undefined + if (value == null) { // Using loose equality (==) checks for both null and undefined + return true + } + + // Check if the value is an object (but not null, which typeof also calls "object") + if (typeof value === 'object') { + // A robust check for an empty object: ensure its constructor is Object and it has no own properties + return Object.keys(value).length === 0 && value.constructor === Object + } + + // Other non-object, non-null values (like strings, numbers, booleans) + // are not considered "empty objects" or "null" by this definition. + return false + } + useEffect(() => { if (params) { let dataList = [] Object.keys(params).forEach((key) => { - let value = params[key] - // For array values - if (Array.isArray(value)) { - value = JSON.stringify(value) + // skip null or undefined values + if (isEmptyOrNull(params[key])) { + dataList.push({ + param: key, + Value: "null" + }) + } else { + let value = params[key] + // For array values + if (Array.isArray(value)) { + value = JSON.stringify(value) + } + dataList.push({ + param: key, + Value: value != null ? value : "null" + }) } - dataList.push({ - param: key, - Value: value != null ? value : "null" - }) }) setData(dataList) } diff --git a/renderer/components/mainPages/application.jsx b/renderer/components/mainPages/application.jsx index 46f881db..55d746f0 100644 --- a/renderer/components/mainPages/application.jsx +++ b/renderer/components/mainPages/application.jsx @@ -358,9 +358,8 @@ const ApplicationPage = ({ pageId }) => { * @param {Array} columnsArray An array of the columns of the dataset */ const checkWarnings = (columnsArray) => { - let datasetColsString = JSON.stringify(columnsArray) - let modelColsString = JSON.stringify(modelFeatures) - if (datasetColsString !== modelColsString && modelFeatures && columnsArray) { + if (!modelFeatures.every((col) => columnsArray.includes(col)) && modelFeatures && columnsArray) { + const missingCols = modelFeatures.filter(col => !columnsArray.includes(col)) setDatasetHasWarning({ state: true, tooltip: ( @@ -371,17 +370,9 @@ const ApplicationPage = ({ pageId }) => {
-

Needed columns:

+

Missing columns:

    - {modelFeatures.sort().map((col) => { - return
  • {col}
  • - })} -
- - -

Received columns:

-
    - {columnsArray.sort().map((col) => { + {missingCols.map((col) => { return
  • {col}
  • })}
diff --git a/renderer/components/mainPages/modelViewer.jsx b/renderer/components/mainPages/modelViewer.jsx index 077522f3..38c07373 100644 --- a/renderer/components/mainPages/modelViewer.jsx +++ b/renderer/components/mainPages/modelViewer.jsx @@ -74,12 +74,19 @@ const ModelViewer = ({ id }) => { )} {/* Machine Learning Type Section */} - {renderCardSection( + {data.ml_type && renderCardSection( "Machine Learning Type",

{data.ml_type}

, "pi pi-circle" )} + {/* Machine Learning Algorithm Section */} + {data.algorithm &&renderCardSection( + "Machine Learning Algorithm", +

{data.algorithm}

, + "pi pi-cogs" + )} + {/* Model Threshold Section */} {data.model_threshold && renderCardSection( "Model's Threshold", diff --git a/renderer/components/mainPages/settings.jsx b/renderer/components/mainPages/settings.jsx index a02badfe..64bbafd8 100644 --- a/renderer/components/mainPages/settings.jsx +++ b/renderer/components/mainPages/settings.jsx @@ -68,32 +68,35 @@ const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterS } /** - * Get the settings from the main process - * if the conda path is defined in the settings, set it - * Check if the server is running and set the state + * Get the settings from the main process on mount */ useEffect(() => { + checkServer() + checkMongoIsRunning() + getJupyterStatus() ipcRenderer.invoke("get-settings").then((receivedSettings) => { console.log("received settings", receivedSettings) setSettings(receivedSettings) - if (pythonEmbedded.pythonEmbedded) { - setCondaPath(pythonEmbedded.pythonEmbedded) - } else if (receivedSettings?.condaPath) { + if (receivedSettings?.condaPath) { setCondaPath(receivedSettings?.condaPath) } if (receivedSettings?.seed) { setSeed(receivedSettings?.seed) } }) - // ipcRenderer.invoke("server-is-running").then((status) => { - // setServerIsRunning(status) - // console.log("server is running", status) - // }) - checkMongoIsRunning() - checkServer() - getJupyterStatus() }, []) + /** + * Get the settings from the main process + * if the conda path is defined in the settings, set it + * Check if the server is running and set the state + */ + useEffect(() => { + if (pythonEmbedded.pythonEmbedded && !condaPath) { + setCondaPath(pythonEmbedded.pythonEmbedded) + } + }, [pythonEmbedded]) + /** * Save the settings in the main process * @param {Object} newSettings - New settings object diff --git a/renderer/components/mainPages/superset/SupersetFrame.jsx b/renderer/components/mainPages/superset/SupersetFrame.jsx index 5dace1e0..fcafff8c 100644 --- a/renderer/components/mainPages/superset/SupersetFrame.jsx +++ b/renderer/components/mainPages/superset/SupersetFrame.jsx @@ -299,7 +299,7 @@ const SupersetDashboard = () => { }) } else if (system === "darwin") { // macOS - exec(`pkill -f superset`, (err, stdout, stderr) => { + exec(`/usr/bin/pkill -f superset`, (err, stdout, stderr) => { if (err) { console.error(err) toast.error("Error killing Superset", {autoClose: 5000}) diff --git a/renderer/styles/flow/reactFlow.css b/renderer/styles/flow/reactFlow.css index 10a7effc..48c4ffea 100644 --- a/renderer/styles/flow/reactFlow.css +++ b/renderer/styles/flow/reactFlow.css @@ -121,7 +121,6 @@ } .draggable-side-node.p-card { - background: #f3f3f3; color: #495057; box-shadow: 2px 2px 3px -1px rgb(0 0 0 / 20%), diff --git a/renderer/styles/theme.css b/renderer/styles/theme.css index 88d54a0c..5e529757 100644 --- a/renderer/styles/theme.css +++ b/renderer/styles/theme.css @@ -15,7 +15,7 @@ --button-bg: #0d6efd; --button-text: #ffffff; --button-hover: #0b5ed7; - --card-bg: #ffffff; + --card-bg: #f8f9fa; --card-border: #dee2e6; --input-bg: #ffffff; --input-border: #ced4da; @@ -105,7 +105,6 @@ label { .card-header, .p-card-header { - background-color: var(--card-bg) !important; border-bottom-color: var(--border-color) !important; color: var(--text-primary) !important; }