v1.6.2: Bereite Techniker-Modell vor & korrigiere/ergänze Modi (Alignment, Args)

- 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).
This commit is contained in:
2025-04-16 09:27:53 +00:00
parent da09cbb448
commit 4e38be6a81

View File

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