From 852d1e282871c7f2edc70221f88c3034c8b5a4c9 Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 2 Jun 2025 13:16:12 +0000 Subject: [PATCH] bugfix --- brancheneinstufung.py | 276 +++++++++++++++++------------------------- 1 file changed, 111 insertions(+), 165 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index f4d376ea..f5b1bb5e 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -8615,193 +8615,101 @@ class DataProcessor: # Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model (denselben Block). # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_numeric_filter_value (Block 5). def _predict_technician_bucket(self, row_data): - """ - Fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile - mit dem trainierten ML-Modell durch. Laedt das Modell und den Imputer bei Bedarf. + company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() + self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") - Args: - row_data (list): Die Rohdaten fuer die Zeile. - - Returns: - str: Der vorhergesagte Bucket-Label oder "FEHLER Schaetzung" bei Fehler/kein Ergebnis. - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Logge den Start der ML-Schaetzung fuer diese Zeile - company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() # Block 1 Column Map - self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") # <<< GEÄNDERT - - # Laden Sie das Modell, den Imputer und die erwarteten Feature-Spalten, falls noch nicht geschehen. - # Diese werden als Attribute der DataProcessor Instanz gespeichert (_load_ml_model denselben Block). if self.model is None or self.imputer is None or self._expected_features is None: - self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...") # <<< GEÄNDERT + self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...") try: - # Der Aufruf von _load_ml_model (denselben Block) ist nicht mit retry_on_failure dekoriert, - # da das Laden lokaler Dateien nicht wiederholt werden muss. Fehler deuten auf ein permanentes Problem hin. - self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt globale Konstanten (Block 1) - - # Pruefe erneut, ob das Laden erfolgreich war. + self._load_ml_model(MODEL_FILE, IMPUTER_FILE) if self.model is None or self.imputer is None or self._expected_features is None: - self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen. Kann ML-Schaetzung nicht durchfuehren.") # <<< GEÄNDERT - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck, wenn Laden fehlschlug - - self.logger.info("ML-Modell, Imputer und Feature-Spalten erfolgreich geladen.") # <<< GEÄNDERT - - except Exception as e: - # Fange Fehler beim Laden ab und logge sie. - self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer/Feature-Spalten: {e}") # <<< GEÄNDERT - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT - # Geben Sie einen Fehlerwert zurueck. - return f"FEHLER Laden: {str(e)[:100]}..." # Signalisiert Ladefehler (gekuerzt) - - - # --- Bereiten Sie die Daten fuer DIESE EINE ZEILE fuer die Vorhersage vor --- + self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen. Kann ML-Schaetzung nicht durchfuehren.") + return "FEHLER Schaetzung (Modell-Laden)" + self.logger.info("ML-Modell, Imputer und Feature-Spalten erfolgreich geladen.") + except Exception as e_load_model: + self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer/Feature-Spalten: {e_load_model}") + self.logger.debug(traceback.format_exc()) + return f"FEHLER Laden: {str(e_load_model)[:100]}..." try: - # Diese Logik ist aehnlich wie in prepare_data_for_modeling (denselben Block), - # aber nur fuer eine einzelne Zeile und muss mit den exakt gleichen - # Spaltennamen, Normalisierungs- und Encoding-Schritten arbeiten wie das Training. + # Hole die konsolidierten Werte aus der row_data (Spalten BD und BE) + # Diese sollten durch vorherige Schritte (z.B. im Chat-Block von _process_single_row + # oder durch run_plausibility_checks_batch) bereits befüllt sein. + final_umsatz_val_str = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)") + final_ma_val_str = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)") + branche_val_str = self._get_cell_value_safe(row_data, "Chat Vorschlag Branche") # Oder CRM Branche, je nach Trainingsdaten - # Hole die benoetigten Spaltenwerte fuer diese Zeile (basierend auf COLUMN_MAP keys Block 1) - row_values = { - # "CRM Name": self._get_cell_value_safe(row_data, "CRM Name"), # Nicht benoetigt fuer Vorhersage - "CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"), # Block 1 Column Map - "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"), # Block 1 Column Map - "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), # Block 1 Column Map - "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), # Block 1 Column Map - "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), # Block 1 Column Map - # Technikerzahl wird fuer die Vorhersage NICHT benoetigt - # "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), + # Numerische Werte für Vorhersage holen + # Wichtig: get_numeric_filter_value gibt 0 zurück, was wir als NaN für Imputation wollen + umsatz_for_pred = get_numeric_filter_value(final_umsatz_val_str, is_umsatz=True) + ma_for_pred = get_numeric_filter_value(final_ma_val_str, is_umsatz=False) + + umsatz_for_pred = np.nan if umsatz_for_pred == 0 else umsatz_for_pred + ma_for_pred = np.nan if ma_for_pred == 0 else ma_for_pred + + # Erstelle das 'is_part_of_group' Feature + parent_d_val = self._get_cell_value_safe(row_data, "Parent Account Name").strip().lower() + parent_o_val = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip().lower() + parent_p_val = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip().lower() + + cond1_pred = bool(parent_d_val and parent_d_val != 'k.a.') + cond2_o_pred = bool(parent_o_val and parent_o_val != 'k.a.') + cond2_p_pred = parent_p_val == 'x' + cond2_pred = cond2_o_pred and cond2_p_pred + is_group_val = 1 if cond1_pred | cond2_pred else 0 + + # Erstelle einen DataFrame für diese eine Zeile mit den Spaltennamen, + # die für das One-Hot-Encoding und die Angleichung an _expected_features benötigt werden. + # Die Namen hier MÜSSEN mit den internen Namen aus prepare_data_for_modeling übereinstimmen, + # BEVOR das One-Hot-Encoding dort stattfand (für Branche) und NACH der Konsolidierung. + single_row_dict = { + 'Finaler_Umsatz_ML': [umsatz_for_pred], # Konsolidierter Name aus prepare_data_for_modeling + 'Finaler_Mitarbeiter_ML': [ma_for_pred], # Konsolidierter Name aus prepare_data_for_modeling + 'branche_crm': [str(branche_val_str).strip() if branche_val_str else 'Unbekannt'], # Interner Name vor One-Hot + 'is_part_of_group': [is_group_val] } - - # Erstellen Sie einen temporaeren DataFrame fuer diese eine Zeile aus den extrahierten Werten - df_single_row = pd.DataFrame([row_values]) - - - # --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) --- - # Nutzt globale Funktion get_numeric_filter_value (Block 5) - ERSETZT get_valid_numeric - # Diese Funktion gibt numerische Werte (Float/Int) oder 0/NaN zurueck. - # Stellen Sie sicher, dass die Spalten existieren, bevor apply aufgerufen wird. - # Diese Spalten sollten aus row_values extrahiert worden sein, wenn COLUMN_MAP korrekt ist. - crm_umsatz_series = df_single_row['CRM Umsatz'].apply(lambda x: get_numeric_filter_value(x, is_umsatz=True)) if 'CRM Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # KORRIGIERT: Lambda hinzugefügt - wiki_umsatz_series = df_single_row['Wiki Umsatz'].apply(lambda x: get_numeric_filter_value(x, is_umsatz=True)) if 'Wiki Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # KORRIGIERT: Lambda hinzugefügt - crm_ma_series = df_single_row['CRM Anzahl Mitarbeiter'].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False)) if 'CRM Anzahl Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # KORRIGIERT: Lambda hinzugefügt - wiki_ma_series = df_single_row['Wiki Mitarbeiter'].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False)).astype(float) if 'Wiki Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # KORRIGIERT: Lambda hinzugefügt - - # np.where waehlt den Wiki-Wert, wenn er nicht 0/NaN ist, sonst den CRM-Wert. - # WICHTIG: 0 ist hier das Kennzeichen fuer ungueltig/nicht parsebar/k.A. in get_numeric_filter_value - df_single_row['Finaler_Umsatz'] = np.where( - (wiki_umsatz_series.notna()) & (wiki_umsatz_series > 0), # Wenn Wiki-Wert vorhanden UND > 0 - wiki_umsatz_series, - crm_umsatz_series - ) - - df_single_row['Finaler_Mitarbeiter'] = np.where( - (wiki_ma_series.notna()) & (wiki_ma_series > 0), # Wenn Wiki-Wert vorhanden UND > 0 - wiki_ma_series, - crm_ma_series - ) - - # Pruefen Sie, ob die konsolidierten numerischen Features NaN sind (nachdem 0 als NaN behandelt wird). - # Ersetzen Sie 0 explizit durch NaN für die Imputation, falls get_numeric_filter_value 0 zurückgibt. - df_single_row['Finaler_Umsatz'] = df_single_row['Finaler_Umsatz'].replace(0, np.nan) - df_single_row['Finaler_Mitarbeiter'] = df_single_row['Finaler_Mitarbeiter'].replace(0, np.nan) - - - # ML-Vorhersage kann nicht durchgefuehrt werden, wenn diese komplett fehlen (werden vom Imputer erwartet). - if pd.isna(df_single_row['Finaler_Umsatz'].iloc[0]) and pd.isna(df_single_row['Finaler_Mitarbeiter'].iloc[0]): - self.logger.debug(f" -> ML-Schaetzung uebersprungen: Konsolidierter Umsatz und Mitarbeiter fehlen fuer Zeile.") # <<< GEÄNDERT - return "k.A. (Daten fehlen)" # Gebe spezifischen Wert zurueck - - - # --- Kategoriale Features (Branche) --- - branche_col_name = "CRM Branche" # Original Header Name aus COLUMN_MAP (Block 1) - # Stellen Sie sicher, dass die Spalte existiert und ein String ist. Fuellen Sie NaNs mit 'Unbekannt'. - if branche_col_name not in df_single_row.columns: - self.logger.warning(f"Spalte '{branche_col_name}' nicht im DataFrame fuer ML-Vorhersage gefunden. Behandle als 'Unbekannt'.") # <<< GEÄNDERT - df_single_row[branche_col_name] = 'Unbekannt' # Setze einen Default-Wert - - df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip() - - - # One-Hot Encoding - # WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ (self._expected_features) enthalten, - # auch wenn diese in der einzelnen Zeile nicht vorkommen. - # pd.get_dummies erstellt Spalten nur fuer die Kategorien in df_single_row. - df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False) # dummy_na=False, da NaNs gefuellt - - - # Fugen Sie fehlende Feature-Spalten hinzu (die im Training vorhanden waren, aber in dieser Zeile nicht). - # Stellen Sie die Reihenfolge der Spalten sicher, so wie sie im Training waren (self._expected_features). - # self._expected_features wird von _load_ml_model (denselben Block) geladen. - if self._expected_features is None: - self.logger.error("FEHLER: Erwartete Feature-Spalten fuer ML-Vorhersage nicht geladen. Kann nicht vorhersagen.") # <<< GEÄNDERT - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - # Erstellen Sie einen neuen DataFrame mit allen erwarteten Features und fuellen Sie fehlende mit 0. - # Sicherstellen, dass die Spalten im Ergebnis-DF in der Reihenfolge von self._expected_features sind. + df_single_row = pd.DataFrame.from_dict(single_row_dict) + + # One-Hot Encoding (konsistent zum Training) + # Wichtig: 'branche_crm' muss der Name sein, der im Training one-hot-encodiert wurde. + df_encoded = pd.get_dummies(df_single_row, columns=['branche_crm'], prefix='Branche', dummy_na=False) + + # Angleichung an die im Training verwendeten Features (self._expected_features) df_processed = pd.DataFrame(columns=self._expected_features) - # Kopieren Sie die Werte aus df_encoded, wo Spalten uebereinstimmen. for col in self._expected_features: if col in df_encoded.columns: df_processed[col] = df_encoded[col] else: - df_processed[col] = 0 # Fuege fehlende Spalten mit 0 hinzu - - # Stellen Sie sicher, dass die numerischen Spalten Float sind (Imputer erwartet das oft) - numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - for col in numeric_features_for_imputation: - if col in df_processed.columns: - df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce') # Wandelt NaN in NaN, Fehler in NaN + # Wenn eine One-Hot-Spalte aus dem Training hier fehlt, wird sie mit 0 gefüllt + # Wenn 'Finaler_Umsatz_ML', 'Finaler_Mitarbeiter_ML' oder 'is_part_of_group' fehlen würde, + # wäre das ein Fehler in _expected_features oder der Logik hier. + df_processed[col] = 0 + + # Numerische Features zu numerischem Typ konvertieren für Imputer + numeric_cols_to_impute_pred = ['Finaler_Umsatz_ML', 'Finaler_Mitarbeiter_ML'] + for col in numeric_cols_to_impute_pred: + if col in df_processed.columns: + df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce') + else: # Sollte nicht passieren, wenn _expected_features korrekt ist + self.logger.warning(f"Erwartetes numerisches Feature '{col}' nicht in df_processed für Vorhersage gefunden.") + df_processed[col] = np.nan - # --- Imputation der fehlenden Werte --- - # Muss konsistent mit dem Imputer aus dem Training sein. - # Der Imputer (self.imputer) wird auf die vorbereiteten Features angewendet. - if self.imputer is None: - self.logger.error("FEHLER: ML-Imputer ist nicht geladen. Kann nicht imputieren/vorhersagen.") # <<< GEÄNDERT - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - # Imputer.transform gibt ein Numpy Array zurueck. - df_imputed_array = self.imputer.transform(df_processed) - # Konvertiere das Ergebnis zurueck zu einem DataFrame mit den erwarteten Spaltennamen. + # Imputation + df_imputed_array = self.imputer.transform(df_processed[self._expected_features]) # Stelle sicher, dass Spaltenreihenfolge passt df_imputed = pd.DataFrame(df_imputed_array, columns=self._expected_features) - # Optional: Pruefen Sie, ob nach Imputation NaNs verbleiben (sollte nicht passieren bei SimpleImputer) - # if df_imputed.isna().any().any(): - # self.logger.warning("WARNUNG: NaNs verbleiben nach Imputation.") # <<< GEÄNDERT - - - # --- Vorhersage --- - # Das Decision Tree Modell (self.model) erwartet die vorbereiteten und imputierten Features. - if not self.model: - self.logger.error("FEHLER: ML-Modell ist nicht geladen. Kann nicht vorhersagen.") # <<< GEÄNDERT - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - - # Fuehren Sie die Vorhersage durch. - # predict_proba gibt die Wahrscheinlichkeiten fuer jede Klasse zurueck. + # Vorhersage prediction_proba = self.model.predict_proba(df_imputed) - # prediction_proba ist ein Array von Wahrscheinlichkeiten pro Klasse fuer jede Eingabezeile (hier nur 1 Zeile). - - # Die Klassen-Labels des Modells (z.B. ['Bucket_1', 'Bucket_2', ...]) model_classes = self.model.classes_ - - # Finden Sie den Index der Klasse mit der hoechsten Wahrscheinlichkeit fuer die erste (und einzige) Zeile. predicted_class_index = np.argmax(prediction_proba[0]) - # Holen Sie das entsprechende Label aus den Modell-Klassen. predicted_bucket_label = model_classes[predicted_class_index] - # Logge die Vorhersage auf Debug-Level - self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}' (Wahrscheinlichkeiten: {prediction_proba[0]})") # <<< GEÄNDERT + self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}' (Wahrscheinlichkeiten: {prediction_proba[0]})") + return predicted_bucket_label - - return predicted_bucket_label # Gebe das vorhergesagte Bucket-Label zurueck (String) - - except Exception as e: - # Fange alle unerwarteten Fehler ab, die waehrend der Datenvorbereitung oder Vorhersage auftreten. - self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage fuer Zeile (ML): {e}") # <<< GEÄNDERT - # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. - return f"FEHLER Schaetzung: {str(e)[:100]}..." # Signalisiert Fehler bei der Schaetzung (gekuerzt) + except Exception as e_predict: + self.logger.exception(f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}") + return f"FEHLER Schaetzung: {str(e_predict)[:100]}..." @@ -9008,6 +8916,44 @@ class DataProcessor: df = pd.DataFrame(data_rows, columns=headers) self.logger.info(f"Initialen DataFrame fuer Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") # <<< GEÄNDERT + + # DACH-Filter (basierend auf CRM Land - Spalte G) + crm_land_col_header = headers[COLUMN_MAP["CRM Land"]] # Holt den tatsächlichen Spaltennamen + # Erlaubte Werte für DACH-Länder (Groß- und Kleinschreibung wird durch .str.upper() behandelt) + dach_countries = ["DE", "CH", "AT", "DEUTSCHLAND", "ÖSTERREICH", "SCHWEIZ", "OESTERREICH"] # OESTERREICH hinzugefügt + + # Sicherstellen, dass die Spalte existiert, bevor gefiltert wird + if crm_land_col_header in df.columns: + df = df[df[crm_land_col_header].astype(str).str.upper().isin(dach_countries)].copy() # .copy() um Warnung zu vermeiden + self.logger.info(f"Nach DACH-Filter (basierend auf '{crm_land_col_header}'): {len(df)} Zeilen verbleiben.") + if df.empty: + self.logger.error("Keine DACH-Unternehmen im Datensatz nach Filterung.") + return None + else: + self.logger.error(f"Spalte '{crm_land_col_header}' für DACH-Filter nicht im DataFrame gefunden.") + return None + + # Plausibilitätsfilter (basierend auf Spalten BG und BH) + plausi_umsatz_col_header = headers[COLUMN_MAP["Plausibilität Umsatz"]] + plausi_ma_col_header = headers[COLUMN_MAP["Plausibilität Mitarbeiter"]] + + # Sicherstellen, dass die Spalten existieren + if plausi_umsatz_col_header in df.columns and plausi_ma_col_header in df.columns: + # Filtere Zeilen, bei denen Plausi-Umsatz oder Plausi-MA einen Fehler anzeigt + # Hier gehen wir davon aus, dass Fehler mit "FEHLER_" beginnen. + df = df[~df[plausi_umsatz_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy() + df = df[~df[plausi_ma_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy() + self.logger.info(f"Nach Entfernung von FEHLER-Plausi-Fällen (BG, BH): {len(df)} Zeilen verbleiben.") + if df.empty: + self.logger.error("Keine Zeilen nach Plausi-Filterung übrig.") + return None + # Hier könnten Sie noch spezifischere Filter für bestimmte WARNUNG-Typen einbauen, falls gewünscht + else: + self.logger.error(f"Plausibilitätsspalten '{plausi_umsatz_col_header}' oder '{plausi_ma_col_header}' nicht im DataFrame gefunden.") + return None + + + # --- Spaltenauswahl und Umbenennung --- # Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schluessel (Block 1) # und weisen ihnen interne, einfachere Namen zu, die im DataFrame verwendet werden.