diff --git a/brancheneinstufung.py b/brancheneinstufung.py index b5c53768..d9b09465 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -9038,210 +9038,132 @@ class DataProcessor: - # --- Features konsolidieren (Umsatz, Mitarbeiter) --- - # Nutzt die globale Hilfsfunktion get_numeric_filter_value (Block 5) - ERSETZT get_valid_numeric - cols_to_process = { - 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'), - 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter') +# --- Features konsolidieren (Umsatz, Mitarbeiter) --- + self.logger.debug("Konsolidiere Umsatz und Mitarbeiter für ML-Features...") + cols_to_process_ml = { + 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz_ML'), + 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter_ML') } - - for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): - self.logger.debug(f"Verarbeite und konsolidiere '{base_name}' (Prioritaet: Wiki > CRM)...") # <<< GEÄNDERT - # Sicherstellen, dass die Spalten im df_subset existieren, bevor apply aufgerufen wird. - # Dies sollte durch die Spaltenauswahl oben garantiert sein, aber zur Sicherheit. - wiki_series = df_subset[wiki_col].apply(lambda x: get_numeric_filter_value(x, is_umsatz=(base_name=='Umsatz'))) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index) # KORRIGIERT: Lambda hinzugefügt - crm_series = df_subset[crm_col].apply(lambda x: get_numeric_filter_value(x, is_umsatz=(base_name=='Umsatz'))) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.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_subset[final_col] = np.where( - (wiki_series.notna()) & (wiki_series > 0), # Wenn Wiki-Wert vorhanden UND > 0 - wiki_series, # Nimm den Wiki-Wert - crm_series # Sonst nimm den CRM-Wert (der auch 0/NaN sein kann) + for base_name, (wiki_col_ml, crm_col_ml, final_col_ml) in cols_to_process_ml.items(): + is_umsatz_flag = (base_name == 'Umsatz') + wiki_series = df_subset[wiki_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag)) + crm_series = df_subset[crm_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag)) + + # Wähle Wiki-Wert, wenn vorhanden und > 0, sonst CRM-Wert + df_subset.loc[:, final_col_ml] = np.where( + (wiki_series.notna()) & (wiki_series > 0), + wiki_series, + crm_series ) - # Info-Log ueber Ergebnis - self.logger.info(f" -> {df_subset[final_col].notna().sum()} gueltige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") # <<< GEÄNDERT + # Ersetze 0 explizit durch NaN, damit es von log1p und Imputer korrekt behandelt wird + df_subset.loc[:, final_col_ml] = df_subset[final_col_ml].replace(0, np.nan) + self.logger.info(f" -> {df_subset[final_col_ml].notna().sum()} gueltige '{final_col_ml}' Werte erstellt (von {len(df_subset)} Zeilen).") + + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # +++ NEUER BLOCK: Feature Engineering (Ratio & Log-Transformationen) +++++ + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + self.logger.info("Erstelle zusätzliche Features (Ratio, Log-Transformationen)...") + + if 'Finaler_Umsatz_ML' in df_subset.columns and 'Finaler_Mitarbeiter_ML' in df_subset.columns: + # Umsatz pro Mitarbeiter + ma_for_ratio = df_subset['Finaler_Mitarbeiter_ML'] # Hier sind Nullen schon durch NaN ersetzt + df_subset.loc[:, 'Umsatz_pro_MA_ML'] = df_subset['Finaler_Umsatz_ML'] / ma_for_ratio + df_subset['Umsatz_pro_MA_ML'].replace([np.inf, -np.inf], np.nan, inplace=True) + self.logger.debug(f" -> Feature 'Umsatz_pro_MA_ML' erstellt.") + + # Log-Transformationen (np.log1p(x) berechnet log(1+x), sicher für NaNs) + df_subset.loc[:, 'Log_Finaler_Umsatz_ML'] = np.log1p(df_subset['Finaler_Umsatz_ML']) + df_subset.loc[:, 'Log_Finaler_Mitarbeiter_ML'] = np.log1p(df_subset['Finaler_Mitarbeiter_ML']) + self.logger.debug(f" -> Log-transformierte Features erstellt.") + else: + self.logger.warning("Konsolidierte Umsatz/Mitarbeiter-Spalten nicht gefunden, Feature Engineering übersprungen.") + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++++++ + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # --- Zielvariable vorbereiten (Technikerzahl) --- - techniker_col_internal = "techniker" # Interne Spaltenname nach Umbenennung (aus col_keys_mapping) - self.logger.info(f"Verarbeite Zielvariable '{techniker_col_internal}'...") # <<< GEÄNDERT - - # Sicherstellen, dass die Spalte existiert - if techniker_col_internal not in df_subset.columns: - self.logger.critical(f"FEHLER: Zielvariable '{techniker_col_internal}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.") # <<< GEÄNDERT - return None # Beende die Methode - - # Konvertiere zu Numerisch (Float/Int oder NaN) mit get_numeric_filter_value (Block 5). - # Dies stellt sicher, dass nur gueltige, positive Zahlen verwendet werden. - df_subset['Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False)) # KORRIGIERT: Lambda hinzugefügt - - - # Filtere Zeilen: Behalte nur die mit gueltiger, positiver Technikerzahl (Float > 0). - initial_rows = len(df_subset) - # Hier filtern wir basierend auf der numerischen Spalte, die durch get_numeric_filter_value erstellt wurde. - df_filtered = df_subset[ - df_subset['Anzahl_Servicetechniker_Numeric'].notna() & # Nicht NaN - (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) # Und groesser als 0 - ].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden - filtered_rows = len(df_filtered) - removed_rows = initial_rows - filtered_rows - - # Info, wenn Zeilen entfernt wurden - if removed_rows > 0: - self.logger.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl (Wert <= 0 oder nicht numerisch/parsebar).") # <<< GEÄNDERT - self.logger.info(f"Verbleibende Zeilen fuer Modellierungstraining (mit gueltiger Technikerzahl > 0): {filtered_rows}") # <<< GEÄNDERT - - # Wenn keine Zeilen uebrig bleiben, kann kein Modell trainiert werden. - if filtered_rows == 0: - self.logger.error("FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!") # <<< GEÄNDERT - return None # Beende die Methode - - - # --- Techniker-Buckets erstellen --- - # Die Bins und Labels muessen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind). - # Die Bin-Definition muss so sein, dass alle Werte > 0 einem Bucket zugeordnet werden. - # Beispiel: (-1, 0] -> Bucket 1 (0), (0, 19] -> Bucket 2 (<20), (19, 49] -> Bucket 3 (<50) etc. - # Da wir auf >0 filtern, landet 0 nie im Trainingsset, aber die Bin-Definition muss trotzdem Sinn ergeben. - # Alter Code: - # bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] - # labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] + self.logger.info("Verarbeite Zielvariable 'techniker'...") + techniker_col_internal = "techniker" + df_subset.loc[:, 'Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False)) - # NEUER VORSCHLAG (z.B. 3 Klassen): - bins_new = [-1, 49, 249, float('inf')] # Grenzen: (<=49), (50-249), (>=250) + initial_rows_before_tech_filter = len(df_subset) + df_filtered = df_subset[ + df_subset['Anzahl_Servicetechniker_Numeric'].notna() & + (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) + ].copy() + + removed_rows_tech_filter = initial_rows_before_tech_filter - len(df_filtered) + if removed_rows_tech_filter > 0: + self.logger.info(f"{removed_rows_tech_filter} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl.") + self.logger.info(f"Verbleibende Zeilen fuer Modellierungstraining: {len(df_filtered)}") + + if df_filtered.empty: + self.logger.error("FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!") + return None + + # --- Techniker-Buckets erstellen (mit reduzierter Klassenanzahl) --- + self.logger.info("Erstelle reduzierte Techniker-Buckets (3 Klassen)...") + bins_new = [-1, 49, 249, float('inf')] labels_new = ['Techniker_Klein (0-49)', 'Techniker_Mittel (50-249)', 'Techniker_Gross (250+)'] - - try: - # Erstellen Sie die Bucket-Spalte mit pd.cut - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], # Die numerische Technikerzahl-Spalte - bins=bins, # Die definierten Grenzen - labels=labels, # Die definierten Labels - right=True, # Intervalle sind (links, rechts]. z.B. (0, 19] inkludiert 19. - include_lowest=True # Inkludiert den niedrigsten Wert der ersten Bin (-1) (relevant, falls 0 moeglich waere) - ) - self.logger.info("Techniker-Buckets erstellt.") # <<< GEÄNDERT - - # Pruefe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung und korrekten Bins nicht passieren). - if df_filtered['Techniker_Bucket'].isna().any(): - nan_bucket_rows = df_filtered['Techniker_Bucket'].isna().sum() - self.logger.warning(f"WARNUNG: {nan_bucket_rows} Zeilen mit NaNs in Techniker-Buckets nach pd.cut erstellt. Ueberpruefen Sie die bins/labels oder die Filterung.") # <<< GEÄNDERT - # Entfernen Sie diese Zeilen, da sie nicht zum Trainieren verwendet werden koennen. - df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) # Entferne Zeilen mit NaN im Bucket - self.logger.info(f"Nach Entfernung von {nan_bucket_rows} Zeilen mit NaN Buckets: {len(df_filtered)} Zeilen verbleiben fuer Training.") # <<< GEÄNDERT - # Wenn nach Entfernung keine Zeilen mehr uebrig sind - if len(df_filtered) == 0: - self.logger.error("FEHLER: Keine Zeilen uebrig nach Entfernung von NaN Buckets. Modell kann nicht trainiert werden.") # <<< GEÄNDERT - return None # Beende die Methode - - - # Verteilung der Buckets als Info-Log (absolute Haeufigkeit und Prozent) - self.logger.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz ({len(df_filtered)} Zeilen):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=False).sort_index()}") # <<< GEÄNDERT - self.logger.info(f"Verteilung (Prozent):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).sort_index().round(3)}") # <<< GEÄNDERT - - except Exception as e: - # Fange Fehler beim Erstellen der Buckets ab - self.logger.critical(f"FEHLER beim Erstellen der Techniker-Buckets: {e}") # <<< GEÄNDERT - # Logge den Traceback - self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT - return None # Beende die Methode - - + + df_filtered.loc[:, 'Techniker_Bucket'] = pd.cut( + df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins_new, labels=labels_new, right=True, include_lowest=True + ) + self.logger.info(f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}") + # --- Kategoriale Features vorbereiten (Branche) --- - branche_col_internal = "branche_crm" # Interne Spaltenname nach Umbenennung (aus col_keys_mapping) - self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' fuer One-Hot Encoding...") # <<< GEÄNDERT - - # Sicherstellen, dass die Spalte existiert - if branche_col_internal not in df_filtered.columns: - self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame fuer One-Hot Encoding gefunden.") # <<< GEÄNDERT - return None # Beende die Methode - - # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt'. - # .str.strip() entfernt führende/endende Leerzeichen. - df_filtered[branche_col_internal] = df_filtered[branche_col_internal].astype(str).fillna('Unbekannt').str.strip() - - - # One-Hot Encoding (pd.get_dummies) - # dummy_na=False, da wir NaNs bereits mit 'Unbekannt' gefuellt haben. - # prefix='Branche' ist gut, um die neuen Spalten zu identifizieren. + self.logger.info("Verarbeite kategoriales Feature 'branche_crm' fuer One-Hot Encoding...") + branche_col_internal = "branche_crm" + df_filtered.loc[:, 'branche_crm'] = df_filtered['branche_crm'].astype(str).fillna('Unbekannt').str.strip() df_encoded = pd.get_dummies(df_filtered, columns=[branche_col_internal], prefix='Branche', dummy_na=False) - self.logger.info(f"One-Hot Encoding fuer '{branche_col_internal}' durchgefuehrt. Neue Spaltenanzahl: {len(df_encoded.columns)}") # <<< GEÄNDERT - - + # --- Finale Auswahl der Features fuer das Modell --- - # Identifizieren Sie die Feature-Spalten nach dem Encoding. - # Dies sind alle Spalten, die mit 'Branche_' beginnen (One-Hot), plus die konsolidierten numerischen. - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten - # Fuegen Sie die konsolidierten numerischen Spalten hinzu. - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) + feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Branche_')] + feature_columns_ml.extend([ + 'Log_Finaler_Umsatz_ML', + 'Log_Finaler_Mitarbeiter_ML', + 'Umsatz_pro_MA_ML', + 'is_part_of_group' + ]) + self.logger.info(f"Finale Feature-Auswahl für das Training: {feature_columns_ml}") + target_column_ml = 'Techniker_Bucket' + identification_cols_ml = ['name', 'Anzahl_Servicetechniker_Numeric'] + + final_cols_for_df_ml = identification_cols_ml + feature_columns_ml + [target_column_ml] + missing_final_cols_ml = [col for col in final_cols_for_df_ml if col not in df_encoded.columns] + if missing_final_cols_ml: + self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols_ml}") + return None - # Pruefen Sie, ob die konsolidierten numerischen Spalten ('Finaler_Umsatz', 'Finaler_Mitarbeiter') - # tatsaechlich im DataFrame df_encoded vorhanden sind (sollten sie, wurden oben erstellt). - if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): - self.logger.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame nach Konsolidierung.") # <<< GEÄNDERT - return None # Beende die Methode + df_model_ready = df_encoded[final_cols_for_df_ml].copy() - - target_column = 'Techniker_Bucket' # Die Zielvariable - - - # Erstellen Sie den finalen DataFrame nur mit Features und Target (und Identifikationsspalten). - # Behalten Sie 'name' und 'Anzahl_Servicetechniker_Numeric' fuer Reporting/Debugging. - # 'name' ist der interne Name nach Umbenennung (aus col_keys_mapping). - identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] - # Sicherstellen, dass diese Identifikationsspalten auch im DataFrame existieren. - if not all(col in df_encoded.columns for col in identification_cols): - self.logger.critical(f"FEHLER: Identifikationsspalten {identification_cols} fehlen im DataFrame.") # <<< GEÄNDERT - return None # Beende die Methode - - - # Erstellen Sie die Liste der finalen Spalten fuer den DataFrame - # Stellen Sie sicher, dass alle Feature-Spalten und die Zielspalte auch wirklich im DataFrame sind - # (Koennte fehlen, wenn z.B. Finaler_Umsatz/Mitarbeiter oben fehlschlug und als NaN resultierte, was aber ok ist fuer Imputer). - final_cols_for_df = identification_cols + feature_columns + [target_column] - missing_final_cols = [col for col in final_cols_for_df if col not in df_encoded.columns] - if missing_final_cols: - self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols}") # <<< GEÄNDERT - return None # Beende die Methode - - - # Erstellen Sie den finalen DataFrame mit den ausgewaehlten Spalten. - df_model_ready = df_encoded[final_cols_for_df].copy() # Erstelle eine Kopie - - - # Optional: Konvertieren Sie numerische Spalten explizit zu Float64. - # Dies stellt sicher, dass der Imputer korrekt arbeitet. - for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']: - if col in df_model_ready.columns: # Sicherheitscheck, ob Spalte existiert - # errors='coerce' wandelt Fehler bei der Konvertierung in NaN. Wichtig, da Imputer NaNs erwartet. + numeric_features_to_convert = [ + 'Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'Anzahl_Servicetechniker_Numeric' + ] + for col in numeric_features_to_convert: + if col in df_model_ready.columns: df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') - - # Setzen Sie den Index des DataFrames zurueck, um eine saubere Verarbeitung in den naechsten Schritten - # (z.B. Train/Test-Split) sicherzustellen. drop=True verhindert, dass der alte Index als neue Spalte hinzugefuegt wird. df_model_ready = df_model_ready.reset_index(drop=True) + self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.") + self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") + self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns_ml)}") - # Logge Informationen zum finalen DataFrame - self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.") # <<< GEÄNDERT - self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") # <<< GEÄNDERT - # Logge die Anzahl der Feature-Spalten, nicht die Liste selbst (kann sehr lang sein). - self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") # <<< GEÄNDERT - self.logger.info(f"Ziel-Spalte: {target_column}") # <<< GEÄNDERT - - - # WICHTIG: Info ueber fehlende Werte in den finalen numerischen Features VOR der Imputation. - # Die Imputation selbst erfolgt im Trainingsschritt (train_technician_model Block 31). - numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - nan_counts = df_model_ready[numeric_features_for_imputation].isna().sum() - self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") # <<< GEÄNDERT - # Logge auch, wie viele Zeilen *mindestens* einen NaN in den numerischen Features haben. - rows_with_nan = df_model_ready[numeric_features_for_imputation].isna().any(axis=1).sum() - self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}") # <<< GEÄNDERT - - - return df_model_ready # Gebe den vorbereiteten DataFrame zurueck + numeric_features_for_imputation_ml = [ + 'Log_Finaler_Umsatz_ML', + 'Log_Finaler_Mitarbeiter_ML', + 'Umsatz_pro_MA_ML' + ] + existing_numeric_features = [col for col in numeric_features_for_imputation_ml if col in df_model_ready.columns] + if existing_numeric_features: + nan_counts = df_model_ready[existing_numeric_features].isna().sum() + self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") + rows_with_nan = df_model_ready[existing_numeric_features].isna().any(axis=1).sum() + self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}") + + return df_model_ready # Methode zum Trainieren des ML Modells