From 5a06d777c93ef2f557a2187eebe75ecb043e3cc6 Mon Sep 17 00:00:00 2001 From: Floke Date: Wed, 16 Apr 2025 09:27:53 +0000 Subject: [PATCH] =?UTF-8?q?v1.6.2:=20Bereite=20Techniker-Modell=20vor=20&?= =?UTF-8?q?=20korrigiere/erg=C3=A4nze=20Modi=20(Alignment,=20Args)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Füge neuen Modus `--mode alignment` hinzu, um die Header-Definitionen (Zeilen 1-5) über die Funktion `alignment_demo` ins Hauptblatt zu schreiben (inkl. Sicherheitsabfrage). - Korrigiere das Kommandozeilenargument für das Zeilenlimit von `--row_limit` zu `--limit` im `argparse`-Setup in `main`. - Verbessere die `main`-Funktion, um interaktive `input()`-Abfragen für Modus und Limit nur dann zu stellen, wenn die entsprechenden Argumente nicht über die Kommandozeile bereitgestellt wurden (verhindert Fehler bei `nohup`). Füge Fehlerbehandlung für `input()` hinzu. - Integriere die neue Funktion `prepare_data_for_modeling` zur Aufbereitung der Daten für das geplante Decision-Tree-Modell zur Technikerschätzung (Funktion wird in den bestehenden Modi noch nicht aufgerufen). --- brancheneinstufung.py | 601 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 575 insertions(+), 26 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 4e1248ba..1a66d29a 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -30,7 +30,13 @@ import gender_guesser.detector as gender from urllib.parse import urlparse, urlencode import argparse import pandas as pd -import numpy as np # Für NaN +import numpy as np +from sklearn.model_selection import train_test_split, GridSearchCV +from sklearn.impute import SimpleImputer +from sklearn.tree import DecisionTreeClassifier, export_text +from sklearn.metrics import accuracy_score, classification_report, confusion_matrix +import json # Zum Speichern der Muster als JSON +import pickle # Zum Speichern des trainierten Modells und Imputers # Optional: tiktoken für Token-Zählung (Modus 8) try: @@ -139,6 +145,227 @@ COLUMN_MAP = { "Website Zusammenfassung": 44 # AS } +# Annahme: COLUMN_MAP ist global definiert und enthält mindestens: +# "CRM Name", "CRM Branche", "CRM Umsatz", "Wiki Umsatz", +# "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "CRM Anzahl Techniker" (oder wo immer die bekannte Technikerzahl steht) + +# Stelle sicher, dass die globale COLUMN_MAP verfügbar ist +# Beispielhafte Definition (bitte an deine Spalten anpassen!) +# COLUMN_MAP = { ... dein komplettes Mapping ... } + +def prepare_data_for_modeling(sheet_handler): + """ + Lädt Daten aus dem Google Sheet, bereitet sie für das Decision Tree Modell vor: + - Wählt relevante Spalten aus. + - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). + - Filtert nach gültiger Technikerzahl (> 0). + - Erstellt die Zielvariable (Techniker-Bucket). + - Bereitet Features auf (One-Hot Encoding für Branche). + - Behält NaNs in numerischen Features für spätere Imputation. + + Args: + sheet_handler (GoogleSheetHandler): Instanz mit geladenen Sheet-Daten. + + Returns: + pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, + oder None bei Fehlern. + """ + debug_print("Starte Datenvorbereitung für Modellierung...") + + try: + # --- 1. Daten laden & Spalten auswählen --- + all_data = sheet_handler.get_all_data_with_headers() + if len(all_data) <= 5: # Annahme: 5 Header-Zeilen + debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.") + return None + headers = all_data[0] # Nimm die erste Zeile als Header für Pandas + data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen + + # Erstelle DataFrame + df = pd.DataFrame(data_rows, columns=headers) + debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + + # Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!) + required_cols_keys = [ + "CRM Name", # Zur Identifikation, wird später entfernt + "CRM Branche", + "CRM Umsatz", + "Wiki Umsatz", + "CRM Anzahl Mitarbeiter", + "Wiki Mitarbeiter", + "CRM Anzahl Techniker" # ÄNDERE DIESEN SCHLÜSSEL, falls die bekannte Zahl woanders steht! + ] + + # Finde die tatsächlichen Spaltennamen aus den Headern basierend auf COLUMN_MAP Beschreibung (Zeile 4) + # ODER verwende direkt die Spaltennamen, wenn sie stabil sind. + # Hier vereinfacht angenommen, dass die Schlüssel oben die Spaltennamen sind: + try: + # Konvertiere Spaltennamen aus COLUMN_MAP zu echten Spaltennamen im DataFrame (falls nötig) + # Dies ist ein Platzhalter - im echten Code müsstest du die Header-Zeilen parsen + # oder dich darauf verlassen, dass die Schlüssel oben die exakten Spaltennamen sind. + df_subset = df[required_cols_keys].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden + except KeyError as e: + debug_print(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}") + return None + + debug_print(f"Benötigte Spalten ausgewählt.") + + # --- 2. Features konsolidieren (Umsatz, Mitarbeiter) --- + # Hilfsfunktion zur Validierung und Konvertierung + def get_valid_numeric(value_str): + if value_str is None or pd.isna(value_str): return np.nan + try: + # Versuche direkt float zu konvertieren + val = float(value_str) + return val if val > 0 else np.nan # Nur Werte > 0 sind gültig + except (ValueError, TypeError): + # Wenn nicht direkt float, versuche es über extract_numeric_value + # Diese Funktion muss dafür angepasst werden, float oder np.nan zurückzugeben + # num_val_str = extract_numeric_value(str(value_str), is_umsatz=True) # Bsp. Umsatz + # if num_val_str != "k.A.": + # try: + # val = float(num_val_str) + # return val if val > 0 else np.nan + # except ValueError: return np.nan + # else: return np.nan + # --- VEREINFACHUNG für jetzt: Nur direkt konvertierbare Werte --- + cleaned_str = re.sub(r'[^\d.,]', '', str(value_str)).replace(',', '.') # Einfache Reinigung + try: + val = float(cleaned_str) + return val if val > 0 else np.nan + except ValueError: + return np.nan + + + # Konvertiere Quellen-Spalten und wende Priorisierung an + cols_to_process = { + 'Umsatz': ('Wiki Umsatz', 'CRM Umsatz', 'Finaler_Umsatz'), + 'Mitarbeiter': ('Wiki Mitarbeiter', 'CRM Anzahl Mitarbeiter', 'Finaler_Mitarbeiter') + } + + for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): + debug_print(f"Verarbeite '{base_name}' (Wiki: {wiki_col}, CRM: {crm_col})...") + wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric) + crm_numeric = df_subset[crm_col].apply(get_valid_numeric) + + # Priorisierung: Wiki > CRM + df_subset[final_col] = np.where( + wiki_numeric.notna() & (wiki_numeric > 0), # Wenn Wiki gültig + wiki_numeric, + np.where( + crm_numeric.notna() & (crm_numeric > 0), # Sonst, wenn CRM gültig + crm_numeric, + np.nan # Sonst NaN + ) + ) + # Logge, wie viele Werte gefunden wurden + debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") + + # Entferne die Originalspalten (optional) + # df_subset = df_subset.drop(columns=[wiki_col, crm_col]) + + # --- 3. Zielvariable vorbereiten (Technikerzahl) --- + techniker_col = "CRM Anzahl Techniker" # ÄNDERE DAS WENN NÖTIG! + debug_print(f"Verarbeite Zielvariable '{techniker_col}'...") + + # Konvertiere zu Numerisch (Fehler -> NaN) + df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') + + # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl + initial_rows = len(df_subset) + df_filtered = df_subset[ + df_subset['Anzahl_Servicetechniker_Numeric'].notna() & + (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) + ].copy() + filtered_rows = len(df_filtered) + debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl.") + debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") + + if filtered_rows == 0: + debug_print("FEHLER: Keine Zeilen mit gültiger Technikerzahl übrig!") + return None + + # --- 4. Techniker-Buckets erstellen --- + bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen + # Labels sollten keine Sonderzeichen enthalten, die Probleme machen könnten + labels = ['Bucket_1_0', 'Bucket_2_<20', 'Bucket_3_<50', 'Bucket_4_<100', 'Bucket_5_<250', 'Bucket_6_<500', 'Bucket_7_>499'] + df_filtered['Techniker_Bucket'] = pd.cut( + df_filtered['Anzahl_Servicetechniker_Numeric'], + bins=bins, + labels=labels, + right=True # 19 gehört zu <20, 49 zu <50 etc. + ) + debug_print("Techniker-Buckets erstellt.") + debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts()}") + + # --- 5. Kategoriale Features vorbereiten (Branche) --- + branche_col = "CRM Branche" # Annahme: CRM Branche ist die zu verwendende + debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...") + + # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs + df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt') + + # One-Hot Encoding + df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False: keine extra Spalte für NaN + debug_print(f"One-Hot Encoding für Branche durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") + + # --- 6. Finale Auswahl der Features für das Modell --- + # Liste aller Feature-Spalten (One-Hot Branchen + numerische) + feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] + feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) + + # Zielspalte + target_column = 'Techniker_Bucket' + + # Erstelle den finalen DataFrame + df_model_ready = df_encoded[feature_columns + [target_column]].copy() + + # Optional: Spalten auf einfache Typen reduzieren (kann Speicher sparen) + for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']: + df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') + + # Reset Index für saubere Verarbeitung im nächsten Schritt + df_model_ready = df_model_ready.reset_index(drop=True) + + debug_print("Datenvorbereitung abgeschlossen.") + debug_print(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") + debug_print(f"Feature-Spalten: {feature_columns}") + debug_print(f"Ziel-Spalte: {target_column}") + + # WICHTIG: Dieser DataFrame enthält noch NaNs in 'Finaler_Umsatz'/'Finaler_Mitarbeiter'! + # Die Imputation sollte NACH dem Train/Test Split erfolgen. + nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() + debug_print(f"Fehlende Werte in numerischen Features:\n{nan_counts}") + + return df_model_ready + + except Exception as e: + debug_print(f"FEHLER während der Datenvorbereitung: {e}") + import traceback + debug_print(traceback.format_exc()) + return None + +# --- Beispielhafter Aufruf (zum Testen) --- +# if __name__ == '__main__': +# # Annahme: Config, COLUMN_MAP, debug_print sind definiert +# # Annahme: GoogleSheetHandler existiert und verbindet sich +# Config.load_api_keys() # Nur falls für extract_numeric_value nötig +# LOG_FILE = create_log_filename("dataprep_test") +# debug_print("Starte Test der Datenvorbereitung...") +# try: +# sheet_handler_instance = GoogleSheetHandler() +# prepared_df = prepare_data_for_modeling(sheet_handler_instance) +# if prepared_df is not None: +# print("\n--- Vorbereiteter DataFrame (erste 5 Zeilen): ---") +# print(prepared_df.head()) +# print("\n--- DataFrame Info: ---") +# prepared_df.info() +# print("\n--- Beschreibung numerischer Features: ---") +# print(prepared_df[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].describe()) +# else: +# print("Datenvorbereitung fehlgeschlagen.") +# except Exception as e: +# print(f"Fehler beim Testaufruf: {e}") # ==================== RETRY-DECORATOR ==================== def retry_on_failure(func): @@ -2507,6 +2734,201 @@ class DataProcessor: debug_print(f"Modus 22 abgeschlossen. {rows_processed} Websites ergänzt.") + def prepare_data_for_modeling(self): # Wird zu einer Methode + """ + Lädt Daten aus dem Google Sheet über den sheet_handler, + bereitet sie für das Decision Tree Modell vor: + - Wählt relevante Spalten aus. + - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). + - Filtert nach gültiger Technikerzahl (> 0). + - Erstellt die Zielvariable (Techniker-Bucket). + - Bereitet Features auf (One-Hot Encoding für Branche). + - Behält NaNs in numerischen Features für spätere Imputation. + + Returns: + pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, + oder None bei Fehlern. + """ + debug_print("Starte Datenvorbereitung für Modellierung...") + + try: + # --- 1. Daten laden & Spalten auswählen --- + # Zugriff auf sheet_values über self.sheet_handler + if not self.sheet_handler or not self.sheet_handler.sheet_values: + debug_print("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen.") + return None + + all_data = self.sheet_handler.sheet_values # Verwende die bereits geladenen Daten + if len(all_data) <= 5: + debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.") + return None + + # Annahme: Die ersten Header (Zeile 1) enthalten die Spaltennamen + headers = all_data[0] + data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen + + df = pd.DataFrame(data_rows, columns=headers) + debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + + # Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!) + # Verwende die global definierte COLUMN_MAP + required_cols_keys_in_map = [ + "CRM Name", + "CRM Branche", + "CRM Umsatz", + "Wiki Umsatz", + "CRM Anzahl Mitarbeiter", + "Wiki Mitarbeiter", + "CRM Anzahl Techniker" # <-- ANPASSEN, falls die bekannte Zahl woanders steht! + ] + + # Finde die tatsächlichen Spaltennamen + cols_to_select = [] + missing_keys = [] + # Wir gehen davon aus, dass die *erste* Zeile (Index 0) die tatsächlichen Headernamen sind + actual_headers = {name: idx for idx, name in enumerate(all_data[0])} + + # Überprüfe, ob alle benötigten Spalten via COLUMN_MAP gefunden werden + # und hole die echten Spaltennamen + # Annahme: COLUMN_MAP mappt Beschreibung (Zeile 4?) zu Index (0-basiert) + col_indices = {} + try: + tech_col_key = "CRM Anzahl Techniker" # Schlüssel in COLUMN_MAP anpassen! + col_indices = { + "name": all_data[0][COLUMN_MAP["CRM Name"]], + "branche": all_data[0][COLUMN_MAP["CRM Branche"]], + "umsatz_crm": all_data[0][COLUMN_MAP["CRM Umsatz"]], + "umsatz_wiki": all_data[0][COLUMN_MAP["Wiki Umsatz"]], + "ma_crm": all_data[0][COLUMN_MAP["CRM Anzahl Mitarbeiter"]], + "ma_wiki": all_data[0][COLUMN_MAP["Wiki Mitarbeiter"]], + "techniker": all_data[0][COLUMN_MAP[tech_col_key]] + } + cols_to_select = list(col_indices.values()) + except KeyError as e: + debug_print(f"FEHLER: Konnte Mapping für Schlüssel '{e}' in COLUMN_MAP nicht finden oder Spalte nicht im Header.") + return None + except IndexError as e: + debug_print(f"FEHLER: Spaltenindex aus COLUMN_MAP ist außerhalb der Grenzen der Header-Zeile: {e}") + return None + + + df_subset = df[cols_to_select].copy() + # Spalten umbenennen für einfachere Handhabung intern + rename_map = {v: k for k, v in col_indices.items()} + df_subset.rename(columns=rename_map, inplace=True) + + debug_print(f"Benötigte Spalten ausgewählt und umbenannt: {list(df_subset.columns)}") + + + # --- 2. Features konsolidieren (Umsatz, Mitarbeiter) --- + # Hilfsfunktion bleibt dieselbe + def get_valid_numeric(value_str): + if value_str is None or pd.isna(value_str) or value_str == '': return np.nan # Leere Strings auch als NaN + try: + val = float(str(value_str).replace(',', '.')) # Komma als Dezimaltrenner erlauben + return val if val > 0 else np.nan + except (ValueError, TypeError): + cleaned_str = re.sub(r'[^\d.]', '', str(value_str)) # Nur Ziffern und Punkt behalten + if not cleaned_str: return np.nan + try: + val = float(cleaned_str) + return val if val > 0 else np.nan + except ValueError: + return np.nan + + # Konvertiere Quellen-Spalten und wende Priorisierung an + cols_to_process = { + 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'), + 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter') + } + + for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): + debug_print(f"Verarbeite '{base_name}' (Wiki: {wiki_col}, CRM: {crm_col})...") + # Stelle sicher, dass Spalten existieren bevor apply angewendet wird + if wiki_col not in df_subset.columns: df_subset[wiki_col] = np.nan + if crm_col not in df_subset.columns: df_subset[crm_col] = np.nan + + wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric) + crm_numeric = df_subset[crm_col].apply(get_valid_numeric) + + df_subset[final_col] = np.where( + wiki_numeric.notna(), wiki_numeric, # Wiki hat Prio 1 (wenn nicht NaN) + np.where(crm_numeric.notna(), crm_numeric, np.nan) # Sonst CRM (wenn nicht NaN) + ) + debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") + + # --- 3. Zielvariable vorbereiten (Technikerzahl) --- + techniker_col = "techniker" # Umbenannter Spaltenname + debug_print(f"Verarbeite Zielvariable '{techniker_col}'...") + + df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') + + initial_rows = len(df_subset) + df_filtered = df_subset[ + df_subset['Anzahl_Servicetechniker_Numeric'].notna() & + (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) + ].copy() + filtered_rows = len(df_filtered) + debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl.") + debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") + + if filtered_rows < 50: # Mindestanzahl für sinnvolles Training + debug_print(f"WARNUNG: Nur {filtered_rows} Zeilen mit gültiger Technikerzahl. Modelltraining möglicherweise nicht sinnvoll.") + # return None # Optional hier abbrechen + if filtered_rows == 0: return None + + + # --- 4. Techniker-Buckets erstellen --- + 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)'] + df_filtered['Techniker_Bucket'] = pd.cut( + df_filtered['Anzahl_Servicetechniker_Numeric'], + bins=bins, labels=labels, right=True + ) + debug_print("Techniker-Buckets erstellt.") + debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") # Relative Häufigkeit + + + # --- 5. Kategoriale Features vorbereiten (Branche) --- + branche_col = "branche" # Umbenannter Spaltenname + debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...") + + df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # Leerzeichen entfernen + + # One-Hot Encoding + df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) + debug_print(f"One-Hot Encoding für Branche durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") + + + # --- 6. Finale Auswahl der Features für das Modell --- + feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] + feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) + + target_column = 'Techniker_Bucket' + original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # Behalte Original-Technikerzahl und Name für Referenz + + df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() + + for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']: + df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') + + df_model_ready = df_model_ready.reset_index(drop=True) + + debug_print("Datenvorbereitung abgeschlossen.") + debug_print(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") + # debug_print(f"Feature-Spalten: {feature_columns}") # Kann sehr lang sein + debug_print(f"Ziel-Spalte: {target_column}") + + nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() + debug_print(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") + + return df_model_ready + + except Exception as e: + debug_print(f"FEHLER während der Datenvorbereitung: {e}") + import traceback + debug_print(traceback.format_exc()) + return None # ==================== MAIN FUNCTION ==================== def main(): @@ -2515,18 +2937,20 @@ def main(): # --- Initialisierung --- # Argument Parser parser = argparse.ArgumentParser(description="Firmen-Datenanreicherungs-Skript") - # Modus Argument - parser.add_argument("--mode", type=str, help="Betriebsmodus (z.B. combined, wiki, website, branch, reeval, website_lookup, website_details, contacts, full_run, alignment)") - # Limit Argument + # HIER NEU: 'train_technician_model' als Option hinzugefügt + parser.add_argument("--mode", type=str, help="Betriebsmodus (z.B. combined, ..., full_run, alignment, train_technician_model)") parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen (für Batch/sequentielle Modi)", default=None) + # Optional: Argumente speziell für das Training hinzufügen? z.B. --output_model_file + # parser.add_argument("--model_out", type=str, default="technician_model.pkl", help="Dateipfad zum Speichern des trainierten Modells") + # parser.add_argument("--patterns_out", type=str, default="technician_patterns.json", help="Dateipfad zum Speichern der extrahierten Muster") args = parser.parse_args() # Lade API Keys Config.load_api_keys() # Betriebsmodus ermitteln - # HIER NEU: 'alignment' hinzugefügt - valid_modes = ["combined", "wiki", "website", "branch", "reeval", "website_lookup", "website_details", "contacts", "full_run", "alignment"] + # HIER NEU: 'train_technician_model' hinzugefügt + valid_modes = ["combined", "wiki", "website", "branch", "reeval", "website_lookup", "website_details", "contacts", "full_run", "alignment", "train_technician_model"] mode = None # Priorisiere Kommandozeilenargumente if args.mode and args.mode.lower() in valid_modes: @@ -2544,18 +2968,17 @@ def main(): print(" website_details:Extrahiert Title/Desc/H-Tags für Zeilen mit 'x' in Spalte A") print(" contacts: Sucht LinkedIn Kontakte via SERP API und schreibt in 'Contacts' Blatt") print(" full_run: Verarbeitet alle Zeilen sequentiell ab der ersten ohne Zeitstempel (AO)") - print(" alignment: Schreibt die Definitions-Header (Zeilen 1-5) ins Hauptblatt (Überschreibt A1:AS5!)") # NEUE Beschreibung + print(" alignment: Schreibt die Definitions-Header (Zeilen 1-5) ins Hauptblatt (Überschreibt A1:AS5!)") + print(" train_technician_model: Bereitet Daten vor, trainiert & evaluiert Decision Tree zur Technikerschätzung") # NEUE Beschreibung try: - # HIER NEU: valid_modes für die Hilfsanzeige verwendet mode_input = input(f"Geben Sie den Modus ein ({', '.join(valid_modes)}): ").strip().lower() if mode_input in valid_modes: mode = mode_input else: print("Ungültige Eingabe. Standardmodus 'combined' wird verwendet.") - mode = "combined" # Standardmodus - # Fehlerbehandlung für input() im Hintergrund + mode = "combined" except OSError as e: - if e.errno == 9: # Bad file descriptor + if e.errno == 9: print("Fehler: Interaktive Modus-Abfrage nicht möglich (läuft im Hintergrund?). Standardmodus 'combined' wird verwendet.") mode = "combined" else: @@ -2568,6 +2991,8 @@ def main(): # Zeilenlimit ermitteln (Logik bleibt unverändert, fragt nur wenn nötig) + # Hinweis: Das Limit wird im 'train_technician_model' Modus aktuell nicht direkt verwendet, + # da alle verfügbaren Daten mit Technikerzahl genutzt werden sollten. row_limit = None if args.limit is not None: if args.limit >= 0: @@ -2576,7 +3001,7 @@ def main(): else: print("Warnung: Negatives Zeilenlimit ignoriert. Kein Limit gesetzt.") row_limit = None - elif mode in ["combined", "wiki", "website", "branch", "full_run"]: # Limit nur für diese Modi relevant + elif mode in ["combined", "wiki", "website", "branch", "full_run"]: # Limit nur für diese Modi interaktiv abfragen try: limit_input = input("Wie viele Zeilen sollen maximal bearbeitet werden? (Enter für alle) ") if limit_input.strip(): @@ -2611,15 +3036,16 @@ def main(): debug_print(f"===== Skript gestartet =====") debug_print(f"Version: {Config.VERSION}") debug_print(f"Betriebsmodus: {mode}") - # Korrigiere Logging für row_limit, wenn es 0 ist - limit_log_text = str(row_limit) if row_limit is not None else 'Unbegrenzt' - if row_limit == 0 and mode in ["combined", "wiki", "website", "branch", "full_run"]: - limit_log_text = '0 (Keine Verarbeitung geplant)' + limit_log_text = str(row_limit) if row_limit is not None else 'N/A für diesen Modus' + if mode in ["combined", "wiki", "website", "branch", "full_run"]: + limit_log_text = str(row_limit) if row_limit is not None else 'Unbegrenzt' + if row_limit == 0: + limit_log_text = '0 (Keine Verarbeitung geplant)' debug_print(f"Zeilenlimit: {limit_log_text}") debug_print(f"Logdatei: {LOG_FILE}") # --- Vorbereitung --- - # Lade Branchenschema + # Lade Branchenschema (wird für fast alle Modi benötigt) load_target_schema() # Initialisiere Google Sheet Handler @@ -2627,10 +3053,10 @@ def main(): sheet_handler = GoogleSheetHandler() except Exception as e: debug_print(f"FATAL: Konnte Google Sheet Handler nicht initialisieren: {e}") - print(f"FEHLER: Verbindung zu Google Sheets fehlgeschlagen. Siehe Logdatei: {LOG_FILE}") # Direkte Ausgabe für den User + print(f"FEHLER: Verbindung zu Google Sheets fehlgeschlagen. Siehe Logdatei: {LOG_FILE}") return # Abbruch - # Initialisiere DataProcessor + # Initialisiere DataProcessor (wird für einige Modi gebraucht) data_processor = DataProcessor(sheet_handler) # --- Modusausführung --- @@ -2639,7 +3065,6 @@ def main(): try: if mode in ["wiki", "website", "branch", "combined"]: - # Stelle sicher, dass row_limit nicht 0 ist, sonst startet der Dispatcher umsonst if row_limit == 0: debug_print("Zeilenlimit ist 0. Überspringe Dispatcher-Aufruf.") else: @@ -2653,7 +3078,6 @@ def main(): elif mode == "contacts": process_contact_research(sheet_handler) elif mode == "full_run": - # Behandlung von Limit 0 direkt hier if row_limit == 0: debug_print("Zeilenlimit ist 0. Überspringe sequenzielle Verarbeitung.") else: @@ -2671,7 +3095,6 @@ def main(): debug_print("Keine Zeilen für 'full_run' zu verarbeiten (Limit 0 oder Startindex am Ende).") else: debug_print(f"Startindex {start_index} liegt hinter der letzten Datenzeile. Keine Verarbeitung für 'full_run'.") - # HIER NEU: elif für den alignment Modus elif mode == "alignment": print("\nACHTUNG: Dieser Modus überschreibt die Zellen A1:AS5 im Haupt-Sheet!") print("Diese Zellen enthalten die Spaltendefinitionen (Alignment Demo).") @@ -2679,14 +3102,13 @@ def main(): confirm = input("Möchten Sie wirklich fortfahren? (j/N): ").strip().lower() if confirm == 'j': debug_print("Bestätigung erhalten. Starte Alignment Demo...") - alignment_demo(sheet_handler.sheet) # Rufe die Funktion auf + alignment_demo(sheet_handler.sheet) debug_print("Alignment Demo Aufruf beendet.") else: print("Vorgang abgebrochen.") debug_print("Alignment Demo vom Benutzer abgebrochen.") - # Fehlerbehandlung für input() im Hintergrund except OSError as e: - if e.errno == 9: # Bad file descriptor + if e.errno == 9: print("Fehler: Interaktive Bestätigung nicht möglich (läuft im Hintergrund?). Vorgang abgebrochen.") debug_print("Alignment Demo abgebrochen (keine interaktive Bestätigung möglich).") else: @@ -2696,6 +3118,134 @@ def main(): print("Fehler: Interaktive Bestätigung nicht möglich (EOF). Vorgang abgebrochen.") debug_print("Alignment Demo abgebrochen (EOF).") + # HIER NEU: Block für den Modelltrainings-Modus + elif mode == "train_technician_model": + debug_print("Starte Modus: train_technician_model") + + # 1. Daten vorbereiten + prepared_df = prepare_data_for_modeling(sheet_handler) + + if prepared_df is not None and not prepared_df.empty: + # 2. Train/Test Split + debug_print("Aufteilen der Daten in Trainings- und Testsets...") + try: + X = prepared_df.drop(columns=['Techniker_Bucket']) + y = prepared_df['Techniker_Bucket'] + # Stratify=y ist wichtig, um die Verteilung der Buckets in Train/Test ähnlich zu halten + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.25, random_state=42, stratify=y + ) + debug_print(f"Trainingsdaten: {X_train.shape}, Testdaten: {X_test.shape}") + except Exception as e: + debug_print(f"Fehler beim Train/Test Split: {e}") + X_train, X_test, y_train, y_test = None, None, None, None # Zurücksetzen + + if X_train is not None: + # 3. Imputation fehlender Werte (Umsatz/Mitarbeiter) + debug_print("Imputation fehlender Werte (Median)...") + numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] + try: + imputer = SimpleImputer(strategy='median') + # WICHTIG: Imputer NUR auf Trainingsdaten fitten! + imputer.fit(X_train[numeric_features]) + # Transformiere Trainings- UND Testdaten + X_train_imputed_np = imputer.transform(X_train[numeric_features]) + X_test_imputed_np = imputer.transform(X_test[numeric_features]) + + # Konvertiere zurück zu DataFrames und setze Spaltennamen und Index zurück + X_train[numeric_features] = X_train_imputed_np + X_test[numeric_features] = X_test_imputed_np + + # Speichere den Imputer für spätere Verwendung (z.B. bei neuen Daten) + imputer_filename = "median_imputer.pkl" + with open(imputer_filename, 'wb') as f_imputer: + pickle.dump(imputer, f_imputer) + debug_print(f"Median-Imputer trainiert und gespeichert als '{imputer_filename}'.") + imputation_successful = True + except Exception as e: + debug_print(f"Fehler bei der Imputation: {e}") + imputation_successful = False + + if imputation_successful: + # 4. Modelltraining & Hyperparameter-Tuning (Beispielhaft) + debug_print("Starte Decision Tree Training mit GridSearchCV...") + # Definiere den Parameter-Grid für die Suche + param_grid = { + 'criterion': ['gini', 'entropy'], + 'max_depth': [5, 8, 10, 12, None], # None = unbegrenzt (vorsicht) + 'min_samples_split': [10, 20, 40], + 'min_samples_leaf': [5, 10, 20], + 'ccp_alpha': [0.0, 0.001, 0.005, 0.01] # Für Pruning + } + + # Erstelle Decision Tree Classifier + dtree = DecisionTreeClassifier(random_state=42) + + # Erstelle GridSearchCV Objekt (cv=5 für 5-fache Kreuzvalidierung) + # scoring='accuracy' oder 'f1_weighted' etc. + grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='accuracy', n_jobs=-1, verbose=1) # n_jobs=-1 nutzt alle CPU Kerne + + try: + grid_search.fit(X_train, y_train) + + # Bestes Modell und Parameter ausgeben + best_params = grid_search.best_params_ + best_score = grid_search.best_score_ + best_estimator = grid_search.best_estimator_ + debug_print(f"GridSearchCV abgeschlossen.") + debug_print(f"Beste Parameter gefunden: {best_params}") + debug_print(f"Bester Kreuzvalidierungs-Score (Accuracy): {best_score:.4f}") + + # Speichere das beste Modell + model_filename = "technician_decision_tree_model.pkl" + with open(model_filename, 'wb') as f_model: + pickle.dump(best_estimator, f_model) + debug_print(f"Bestes Modell gespeichert als '{model_filename}'.") + + # 5. Evaluation auf dem Test-Set + debug_print("Evaluiere bestes Modell auf dem Test-Set...") + y_pred = best_estimator.predict(X_test) + + test_accuracy = accuracy_score(y_test, y_pred) + report = classification_report(y_test, y_pred, zero_division=0) + conf_matrix = confusion_matrix(y_test, y_pred) + + debug_print(f"\n--- Evaluationsergebnisse (Test-Set) ---") + debug_print(f"Genauigkeit: {test_accuracy:.4f}") + debug_print(f"Klassifikationsbericht:\n{report}") + debug_print(f"Konfusionsmatrix:\n{conf_matrix}") + print(f"\nModell-Evaluation abgeschlossen. Genauigkeit auf Test-Set: {test_accuracy:.4f}") # Auch für User sichtbar + print(f"Detaillierter Bericht im Logfile: {LOG_FILE}") + + # 6. Muster extrahieren + debug_print("\nExtrahiere Regeln aus dem besten Baum (Textformat)...") + try: + feature_names = list(X_train.columns) # Namen der Features + # Stelle sicher, dass die Label-Namen (Buckets) verfügbar sind + class_names = best_estimator.classes_ # Die Bucket-Labels + + rules_text = export_text(best_estimator, feature_names=feature_names, show_weights=True) # show_weights zeigt Verteilung in Blättern + debug_print(f"--- Baumregeln (Text) ---:\n{rules_text}") + + # Speichere Regeln als Textdatei + patterns_filename_txt = "technician_patterns.txt" + with open(patterns_filename_txt, 'w', encoding='utf-8') as f_rules: + f_rules.write(rules_text) + debug_print(f"Regeln als Text gespeichert in '{patterns_filename_txt}'.") + + # TODO (Optional): Regeln als JSON extrahieren (komplexer) + # Hier müsste man den Baum traversieren (tree_ Attribut) + + except Exception as e_export: + debug_print(f"Fehler beim Extrahieren/Speichern der Baumregeln: {e_export}") + + except Exception as e_train: + debug_print(f"FEHLER während des Modelltrainings/-tunings: {e_train}") + import traceback + debug_print(traceback.format_exc()) + else: + debug_print("Datenvorbereitung fehlgeschlagen oder keine Daten vorhanden. Modus 'train_technician_model' abgebrochen.") + else: debug_print(f"Unbekannter Modus '{mode}' - keine Aktion ausgeführt.") @@ -2710,7 +3260,6 @@ def main(): debug_print(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") debug_print(f"Gesamtdauer: {duration:.2f} Sekunden.") debug_print(f"===== Skript beendet =====") - # Sicherstellen, dass die letzte Log-Nachricht auch geschrieben wird if LOG_FILE: try: with open(LOG_FILE, "a", encoding="utf-8") as f: