Download data_prep.py from elsemaey/ap-invoice-payment-delay-predictor: direct link, hf CLI and curl.
- Browser
- Download file 5.26 kB
-
https://huggingface.co/spaces/elsemaey/ap-invoice-payment-delay-predictor/resolve/main/data_prep.py
- Command line
-
hf download hf://spaces/elsemaey/ap-invoice-payment-delay-predictor/data_prep.py
-
curl -L -o data_prep.py https://huggingface.co/spaces/elsemaey/ap-invoice-payment-delay-predictor/resolve/main/data_prep.py
5.26 kB
| """Load, clean, and featurize AP invoice data. | |
| Accepts either the shipped AP_INVOICE_PAYMENT.csv path or a user-uploaded | |
| DataFrame with the same schema (see REQUIRED_COLUMNS / template.py). | |
| Labels: | |
| - delay_days = Payment_Date - TERMS_DATE, defined only for PAYED invoices. | |
| - is_late = delay_days > threshold (binary, for the classification objective). | |
| Features must be knowable at invoice-approval time (no Payment_Date / Payment_Amount leakage). | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| REQUIRED_COLUMNS = [ | |
| "CHECK_NUMBER", | |
| "Supplier_Number", | |
| "Supplier_Name", | |
| "Payment_Status", | |
| "Payment_Amount", | |
| "Currency", | |
| "Payment_Date", | |
| "INVOICE_NUMBER", | |
| "INVOICE_AMOUNT", | |
| "Invoice_Status", | |
| "INVOICE_DATE", | |
| "TERMS_DATE", | |
| ] | |
| DATE_COLUMNS = ["INVOICE_DATE", "TERMS_DATE", "Payment_Date"] | |
| FEATURE_COLS = [ | |
| "invoice_amount_log", | |
| "currency", | |
| "supplier_freq", | |
| "supplier_hist_delay", | |
| "invoice_month", | |
| "invoice_day_of_week", | |
| "invoice_day_of_month", | |
| "is_month_end", | |
| ] | |
| def validate_columns(df: pd.DataFrame): | |
| """Strict schema check: uploaded files must carry exactly the template's | |
| column names (order doesn't matter). Returns (is_valid, missing, extra).""" | |
| cols = set(df.columns) | |
| required = set(REQUIRED_COLUMNS) | |
| missing = sorted(required - cols) | |
| extra = sorted(cols - required) | |
| return len(missing) == 0, missing, extra | |
| def parse_dates(df: pd.DataFrame) -> pd.DataFrame: | |
| """Parse date columns. Tries the shipped `DD-Mon-YY` format first (fast, | |
| no warnings); rows that don't match fall back to a lenient dayfirst parse | |
| so near-miss formats from a user-uploaded file still work.""" | |
| df = df.copy() | |
| for col in DATE_COLUMNS: | |
| if pd.api.types.is_datetime64_any_dtype(df[col]): | |
| continue | |
| parsed = pd.to_datetime(df[col], format="%d-%b-%y", errors="coerce") | |
| needs_fallback = parsed.isna() & df[col].notna() | |
| if needs_fallback.any(): | |
| parsed.loc[needs_fallback] = pd.to_datetime( | |
| df.loc[needs_fallback, col], dayfirst=True, errors="coerce" | |
| ) | |
| df[col] = parsed | |
| return df | |
| def load_source(source) -> pd.DataFrame: | |
| """`source` is either a CSV path or an already-loaded DataFrame (e.g. from | |
| st.file_uploader).""" | |
| df = source.copy() if isinstance(source, pd.DataFrame) else pd.read_csv(source) | |
| return parse_dates(df) | |
| def add_label(df: pd.DataFrame) -> pd.DataFrame: | |
| df = df.copy() | |
| df["delay_days"] = (df["Payment_Date"] - df["TERMS_DATE"]).dt.days | |
| return df | |
| def add_classification_label(df: pd.DataFrame, threshold: float) -> pd.DataFrame: | |
| df = df.copy() | |
| df["is_late"] = (df["delay_days"] > threshold).astype(int) | |
| return df | |
| def add_base_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """Features that only need the row itself (no cross-row lookups).""" | |
| df = df.copy() | |
| df["invoice_amount_log"] = np.log1p(df["INVOICE_AMOUNT"].clip(lower=0)) | |
| df["invoice_month"] = df["INVOICE_DATE"].dt.month | |
| df["invoice_day_of_week"] = df["INVOICE_DATE"].dt.dayofweek | |
| df["invoice_day_of_month"] = df["INVOICE_DATE"].dt.day | |
| df["is_month_end"] = (df["INVOICE_DATE"].dt.day >= 25).astype(int) | |
| df["currency"] = df["Currency"] | |
| return df | |
| def build_supplier_lookup(train_df: pd.DataFrame) -> pd.DataFrame: | |
| """Supplier frequency + historical mean delay, fit ONLY on training rows. | |
| Returned lookup is applied to train/test/inference alike — this is what keeps | |
| the encoding leakage-safe (test-set and live invoices never contribute to it). | |
| """ | |
| global_mean_delay = train_df["delay_days"].mean() | |
| grp = train_df.groupby("Supplier_Number") | |
| lookup = grp.agg( | |
| supplier_freq=("Supplier_Number", "count"), | |
| supplier_hist_delay=("delay_days", "mean"), | |
| ).reset_index() | |
| lookup.attrs["global_mean_delay"] = global_mean_delay | |
| lookup.attrs["global_freq"] = 1 | |
| return lookup | |
| def apply_supplier_lookup(df: pd.DataFrame, lookup: pd.DataFrame) -> pd.DataFrame: | |
| df = df.merge(lookup, on="Supplier_Number", how="left") | |
| df["supplier_freq"] = df["supplier_freq"].fillna(lookup.attrs.get("global_freq", 1)) | |
| df["supplier_hist_delay"] = df["supplier_hist_delay"].fillna( | |
| lookup.attrs.get("global_mean_delay", df["supplier_hist_delay"].mean()) | |
| ) | |
| return df | |
| def prepare_dataset(source): | |
| """Returns (labeled_df, unpaid_df) with base features added but WITHOUT the | |
| supplier lookup applied yet (that must be fit on the train split only). | |
| `source` is either a CSV path or an already-validated DataFrame. | |
| """ | |
| df = load_source(source) | |
| df = add_label(df) | |
| df = add_base_features(df) | |
| labeled = df[(df["Invoice_Status"] == "APPROVED") & (df["Payment_Status"] == "PAYED")].copy() | |
| unpaid = df[(df["Invoice_Status"] == "APPROVED") & (df["Payment_Status"] == "NOT PAYED")].copy() | |
| labeled = labeled.sort_values("INVOICE_DATE").reset_index(drop=True) | |
| return labeled, unpaid | |
| def time_based_split(labeled: pd.DataFrame, test_frac: float = 0.2): | |
| n = len(labeled) | |
| cut = int(n * (1 - test_frac)) | |
| train_df = labeled.iloc[:cut].copy() | |
| test_df = labeled.iloc[cut:].copy() | |
| return train_df, test_df | |