This commit is contained in:
2025-06-02 13:16:12 +00:00
parent 17c21c2036
commit 852d1e2828

View File

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