Refactor: ML-Datenvorbereitung mit neuen Features & Klassen, Bugfixes

- Refactoring der Funktion `prepare_data_for_modeling`:
  - Neue Bucket-Einteilung: Die Anzahl der Zielklassen (Techniker-Buckets) wurde zur Verbesserung der Robustheit von 7 auf 3 Klassen reduziert ('Klein', 'Mittel', 'Gross').
  - Feature Engineering: Zusätzliche Features (`Umsatz_pro_MA`, `Log_Umsatz`, `Log_Mitarbeiter`) werden nun dynamisch erstellt und für das Training verwendet, um die Vorhersagekraft zu erhöhen.
  - Feature-Auswahl: Die finalen Features für das Modelltraining wurden auf die neuen, transformierten numerischen Features umgestellt.
  - Datenfilterung: Filter für DACH-Region und Plausibilität (Ausschluss von `FEHLER`-Fällen) wurden direkt in die Datenvorbereitung integriert.
- Bugfix: Ein `NameError` in `prepare_data_for_modeling` wurde behoben. Der Code zur Erstellung des 'is_part_of_group'-Features greift nun korrekt auf die Spalten des Pandas DataFrames statt auf eine nicht existierende `row_data`-Variable zu.
- Bugfix: Ein `SyntaxError` im `col_keys_mapping`-Dictionary wurde durch ein fehlendes Komma behoben.
- Code-Struktur: Der gesamte Datenverarbeitungsfluss innerhalb von `prepare_data_for_modeling` wurde für bessere Lesbarkeit und Konsistenz überarbeitet.
This commit is contained in:
2025-06-18 08:32:29 +00:00
parent 4b126026f8
commit 8602b338eb

View File

@@ -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