Καθαρισμός Δεδομένων στην Python: Οδηγός με Pandas, Scikit-Learn & Pyjanitor

Πρακτικός οδηγός καθαρισμού και προεπεξεργασίας δεδομένων στην Python. Μάθετε τεχνικές για missing values, outliers, method chaining με pandas, scikit-learn pipelines και pyjanitor.

Αν ασχολείστε με δεδομένα — ακόμα και λίγο — σίγουρα έχετε ακούσει αυτόν τον κανόνα: το 80% του χρόνου σας πηγαίνει στον καθαρισμό και την προετοιμασία των δεδομένων, ενώ μόνο το 20% στην πραγματική ανάλυση ή στην εκπαίδευση μοντέλων. Και ειλικρινά, δεν είναι υπερβολή. Είναι η καθημερινή πραγματικότητα για data scientists, αναλυτές και μηχανικούς δεδομένων σε κάθε τομέα.

Σε αυτόν τον οδηγό θα δούμε ολόκληρο τον κύκλο καθαρισμού και προεπεξεργασίας δεδομένων στην Python. Θα ξεκινήσουμε από τα βασικά του pandas, θα φτιάξουμε αυτοματοποιημένα pipelines με το scikit-learn, θα εξερευνήσουμε εργαλεία σαν το pyjanitor για πιο καθαρό κώδικα, και θα κλείσουμε με πρακτικές συμβουλές που θα κάνουν τη δουλειά σας πιο αποδοτική.

1. Γιατί ο Καθαρισμός Δεδομένων Είναι Κρίσιμος

Πριν βουτήξουμε στον κώδικα, ας δούμε γιατί ο καθαρισμός δεδομένων δεν είναι απλώς ένα τεχνικό βήμα — είναι θεμελιώδης για κάθε αξιόπιστη ανάλυση.

Το Πρόβλημα με τα «Βρώμικα» Δεδομένα

Τα «βρώμικα» δεδομένα μπορεί να πάρουν πολλές μορφές:

  • Ελλιπείς τιμές (Missing values): Κενά πεδία, NaN, None, ή «κρυμμένες» τιμές όπως -999 ή "N/A"
  • Διπλότυπα (Duplicates): Εγγραφές που εμφανίζονται πολλαπλές φορές
  • Ασυνέπειες μορφοποίησης: "Αθήνα", "ΑΘΗΝΑ", "αθήνα", " Αθήνα " — ίδια πόλη, τέσσερις εκδοχές
  • Λανθασμένοι τύποι δεδομένων: Αριθμοί αποθηκευμένοι ως κείμενο, ημερομηνίες ως strings
  • Ακραίες τιμές (Outliers): Τιμές που ξεφεύγουν δραστικά από το αναμενόμενο εύρος
  • Δομικά προβλήματα: Μη ομοιόμορφες στήλες, εσφαλμένα ονόματα, μικτή κωδικοποίηση

Αν τροφοδοτήσετε τέτοια δεδομένα σε ένα ML μοντέλο ή σε ένα reporting dashboard, τα αποτελέσματα θα είναι αναξιόπιστα — ή χειρότερα, παραπλανητικά. Η αρχή «Garbage In, Garbage Out» ισχύει πάντα και παντού.

2. Ρύθμιση Περιβάλλοντος Εργασίας

Πριν ξεκινήσουμε, ας εγκαταστήσουμε τα απαραίτητα εργαλεία και ας φτιάξουμε ένα δείγμα δεδομένων για εξάσκηση:

# Εγκατάσταση απαραίτητων βιβλιοθηκών
pip install pandas numpy scikit-learn pyjanitor matplotlib seaborn
import pandas as pd
import numpy as np
from datetime import datetime

# Δημιουργία δείγματος «βρώμικων» δεδομένων
np.random.seed(42)

data = {
    "customer_name": ["Μαρία Παπ.", "ΓΙΩΡΓΟΣ ΚΩΣΤΟΠΟΥΛΟΣ", "ελένη δημ.",
                       "Μαρία Παπ.", "Νίκος Αντ.", None, "Κώστας Β.",
                       " Αννα Μ. ", "Δημήτρης Κ.", "Σοφία Λ."],
    "age": [28, 35, -5, 28, 150, 42, np.nan, 31, 29, 55],
    "city": ["Αθήνα", "ΘΕΣΣΑΛΟΝΙΚΗ", "πάτρα", "Αθήνα", "Αθήνα",
             "Θεσσαλονίκη", "N/A", "Αθήνα", "Λάρισα", "Ηράκλειο"],
    "salary": ["2500", "3200", "1800", "2500", "4500",
               "2800", "3100", None, "2200", "2900"],
    "registration_date": ["2024-01-15", "15/02/2024", "2024-03-20",
                           "2024-01-15", "2024/04/10", "2024-05-22",
                           "2024-06-30", "2024-07-14", "invalid", "2024-09-01"],
    "category": ["Premium", "Basic", "premium", "Premium", "basic",
                  "Standard", "BASIC", "Premium", "Standard", "basic"]
}

df = pd.DataFrame(data)
print(df)
print(f"\nΜέγεθος: {df.shape}")
print(f"\nΤύποι δεδομένων:\n{df.dtypes}")

Αυτό το dataset περιέχει σκόπιμα κάθε τύπο «ακαθαρσίας» που θα βρείτε στον πραγματικό κόσμο. Λοιπόν, ας τα καθαρίσουμε βήμα-βήμα.

3. Αρχική Εξερεύνηση και Διάγνωση (EDA για Καθαρισμό)

Πριν πιάσετε να «καθαρίζετε», πρέπει πρώτα να καταλάβετε τι έχετε μπροστά σας. Αυτό το βήμα πολλοί το παραλείπουν, αλλά εγώ θα τολμούσα να πω ότι είναι το πιο σημαντικό:

# Γρήγορη επισκόπηση
print(df.info())
print("\n" + "="*50)
print(df.describe(include="all"))

# Έλεγχος ελλιπών τιμών
missing_report = pd.DataFrame({
    "Ελλιπείς": df.isnull().sum(),
    "Ποσοστό (%)": (df.isnull().sum() / len(df) * 100).round(2)
})
print("\nΑναφορά Ελλιπών Τιμών:")
print(missing_report[missing_report["Ελλιπείς"] > 0])

# Έλεγχος διπλοτύπων
print(f"\nΔιπλότυπες γραμμές: {df.duplicated().sum()}")
print(f"Μερικά διπλότυπα (Όνομα): {df['customer_name'].duplicated().sum()}")

# Μοναδικές τιμές ανά στήλη
for col in df.select_dtypes(include="object").columns:
    print(f"\n{col}: {df[col].unique()}")

Ήδη από αυτή τη γρήγορη ματιά, αποκαλύπτονται αρκετά: ελλιπείς τιμές σε πολλές στήλες, ασυνέπειες στη χρήση κεφαλαίων, αρνητικές ηλικίες, μισθοί αποθηκευμένοι ως string, και ημερομηνίες σε ασυνεπή μορφή. Πολλά πράγματα να φτιάξουμε!

4. Καθαρισμός Ονομάτων Στηλών

Ξεκινάμε πάντα από τα βασικά. Η τυποποίηση ονομάτων στηλών κάνει τον κώδικα πιο αναγνώσιμο και λιγότερο επιρρεπή σε σφάλματα:

# Τυποποίηση ονομάτων στηλών
df.columns = (df.columns
    .str.strip()
    .str.lower()
    .str.replace(" ", "_", regex=False)
)

print(df.columns.tolist())
# ['customer_name', 'age', 'city', 'salary', 'registration_date', 'category']

Μια μικρή συμβουλή: Σε παραγωγικά περιβάλλοντα, χρησιμοποιήστε αγγλικά ονόματα στηλών. Αποφεύγετε ειδικούς χαρακτήρες, κενά και τονισμένα γράμματα — πιστέψτε με, θα σας γλιτώσουν πολύ πονοκέφαλο αργότερα.

5. Χειρισμός Ελλιπών Τιμών (Missing Values)

Αυτό είναι ίσως το πιο κρίσιμο κομμάτι του καθαρισμού. Δεν υπάρχει μία «σωστή» λύση — η στρατηγική εξαρτάται πάντα από το πλαίσιο.

5.1 Εντοπισμός Ελλιπών Τιμών

# Πρόσεχε: Οι ελλιπείς τιμές δεν είναι πάντα NaN!
# Μερικές φορές κρύβονται ως "N/A", "", "-", "null", -999 κλπ.

# Αντικατάσταση κωδικοποιημένων ελλιπών τιμών
placeholder_values = ["N/A", "n/a", "NA", "-", "", "null", "NULL", "None"]
df = df.replace(placeholder_values, np.nan)

# Τώρα ελέγχουμε ξανά
print(df.isnull().sum())

5.2 Στρατηγικές Αντιμετώπισης

# Στρατηγική 1: Αφαίρεση γραμμών (μόνο για μικρά ποσοστά ελλιπών)
df_dropped = df.dropna(subset=["customer_name"])  # Αφαίρεση αν λείπει το όνομα

# Στρατηγική 2: Συμπλήρωση με σταθερή τιμή
df["city"] = df["city"].fillna("Άγνωστη")

# Στρατηγική 3: Συμπλήρωση με μέσο/διάμεσο (για αριθμητικά)
df["salary"] = pd.to_numeric(df["salary"], errors="coerce")
median_salary = df["salary"].median()
df["salary"] = df["salary"].fillna(median_salary)

# Στρατηγική 4: Συμπλήρωση με τη συχνότερη τιμή (mode)
df["age"] = df["age"].fillna(df["age"].median())

# Στρατηγική 5: Forward/Backward fill (για χρονοσειρές)
# df["value"] = df["value"].ffill()  # ή .bfill()

print(f"Ελλιπείς τιμές μετά τον καθαρισμό:\n{df.isnull().sum()}")

5.3 Προχωρημένη Αντικατάσταση με Scikit-Learn

from sklearn.impute import SimpleImputer, KNNImputer

# SimpleImputer — αντικατάσταση βάσει στρατηγικής
imputer = SimpleImputer(strategy="median")
df[["age", "salary"]] = imputer.fit_transform(df[["age", "salary"]])

# KNNImputer — αντικατάσταση βάσει πλησιέστερων γειτόνων
# Πιο ακριβής αλλά πιο αργός
knn_imputer = KNNImputer(n_neighbors=3)
# numeric_cols = df.select_dtypes(include=[np.number]).columns
# df[numeric_cols] = knn_imputer.fit_transform(df[numeric_cols])

Tip για pandas 3.0: Χρησιμοποιήστε τους nullable τύπους δεδομένων (π.χ. pd.Int64Dtype(), pd.Float64Dtype()) που υποστηρίζουν εγγενώς τιμές pd.NA. Έτσι αποφεύγετε τη μετατροπή ακέραιων στηλών σε float μόνο και μόνο επειδή υπάρχουν ελλιπείς τιμές.

6. Αντιμετώπιση Διπλοτύπων

Τα διπλότυπα μπορεί να παραμορφώσουν σοβαρά τα αποτελέσματά σας. Ωστόσο, θέλει λίγη προσοχή — δεν είναι πάντα ξεκάθαρο ποιες εγγραφές είναι πράγματι διπλότυπες:

# Εντοπισμός ακριβών διπλοτύπων
print(f"Ακριβή διπλότυπα: {df.duplicated().sum()}")

# Εντοπισμός μερικών διπλοτύπων (βάσει συγκεκριμένων στηλών)
print(f"Διπλότυπα ονόματα: {df.duplicated(subset=['customer_name']).sum()}")

# Εμφάνιση των διπλοτύπων
duplicates = df[df.duplicated(subset=["customer_name"], keep=False)]
print("\nΔιπλότυπες εγγραφές:")
print(duplicates)

# Αφαίρεση διπλοτύπων — κρατάμε την πρώτη εμφάνιση
df = df.drop_duplicates(subset=["customer_name"], keep="first")
print(f"\nΜέγεθος μετά αφαίρεση: {df.shape}")

Σημαντικό: Πριν αφαιρέσετε διπλότυπα, σκεφτείτε αν πρόκειται πράγματι για διπλές εγγραφές. Δύο πελάτες μπορεί να έχουν το ίδιο όνομα! Χρησιμοποιήστε σύνθετα κλειδιά (π.χ. όνομα + ημερομηνία εγγραφής) για πιο ασφαλή αναγνώριση.

7. Τυποποίηση Κειμένου και Κατηγοριών

Οι ασυνέπειες στα κείμενα είναι εκνευριστικά συχνές. Μια "Αθήνα" εδώ, μια "ΑΘΗΝΑ" εκεί, κι ξαφνικά έχετε τρεις ψεύτικες κατηγορίες αντί για μία:

# Πριν τον καθαρισμό
print("Μοναδικές κατηγορίες:", df["category"].unique())
# ['Premium', 'Basic', 'premium', 'basic', 'Standard', 'BASIC']

# Τυποποίηση κατηγοριών
df["category"] = (df["category"]
    .str.strip()           # Αφαίρεση κενών
    .str.lower()           # Μετατροπή σε πεζά
    .str.capitalize()      # Κεφαλαίο μόνο το πρώτο γράμμα
)

print("Μετά καθαρισμό:", df["category"].unique())
# ['Premium', 'Basic', 'Standard']

# Τυποποίηση ονομάτων πόλεων
df["city"] = (df["city"]
    .str.strip()
    .str.title()
)

# Αντικατάσταση παραλλαγών με κανονικοποιημένες τιμές
city_mapping = {
    "Θεσσαλονικη": "Θεσσαλονίκη",
    "Πατρα": "Πάτρα",
    "Αθηνα": "Αθήνα",
    "Λαρισα": "Λάρισα",
    "Ηρακλειο": "Ηράκλειο"
}
df["city"] = df["city"].replace(city_mapping)

Χρήση Regular Expressions για Πιο Σύνθετο Καθαρισμό

import re

# Καθαρισμός ονομάτων πελατών
df["customer_name"] = (df["customer_name"]
    .str.strip()
    .str.title()                         # Σωστή κεφαλαιοποίηση
    .str.replace(r"\s+", " ", regex=True)  # Αφαίρεση πολλαπλών κενών
)

# Εξαγωγή δομημένων δεδομένων από ελεύθερο κείμενο
# Παράδειγμα: εξαγωγή ταχυδρομικού κώδικα από διεύθυνση
addresses = pd.Series([
    "Σταδίου 42, 10564 Αθήνα",
    "Τσιμισκή 100, ΤΚ 54622, Θεσσαλονίκη",
    "Κολοκοτρώνη 8, 26221 Πάτρα"
])

postal_codes = addresses.str.extract(r"(\d{5})")
print(postal_codes)

8. Μετατροπή Τύπων Δεδομένων

Η σωστή μετατροπή τύπων βελτιώνει τόσο την ακρίβεια της ανάλυσης όσο και τη χρήση μνήμης. Κι ας είναι ένα βήμα που πολλοί υποτιμούν:

# Μετατροπή μισθού σε αριθμητικό
df["salary"] = pd.to_numeric(df["salary"], errors="coerce")

# Μετατροπή ηλικίας σε ακέραιο (nullable)
df["age"] = df["age"].astype("Int64")  # Nullable integer

# Μετατροπή ημερομηνίας — χειρισμός πολλαπλών μορφών
def parse_date_flexible(date_str):
    if pd.isna(date_str):
        return pd.NaT
    formats = ["%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%d-%m-%Y"]
    for fmt in formats:
        try:
            return pd.to_datetime(date_str, format=fmt)
        except (ValueError, TypeError):
            continue
    return pd.NaT

df["registration_date"] = df["registration_date"].apply(parse_date_flexible)

# Μετατροπή κατηγοριών σε categorical τύπο
df["category"] = df["category"].astype("category")
df["city"] = df["city"].astype("category")

print(df.dtypes)
print(f"\nΜνήμη: {df.memory_usage(deep=True).sum() / 1024:.2f} KB")

Αξίζει να σημειωθεί: Στο pandas 3.0 ο νέος τύπος StringDtype βασισμένος σε PyArrow είναι πλέον η προεπιλογή. Αυτό σημαίνει καλύτερη απόδοση στις λειτουργίες κειμένου και σωστή διαχείριση ελλιπών τιμών χωρίς μετατροπή σε object dtype.

9. Εντοπισμός και Χειρισμός Ακραίων Τιμών (Outliers)

Οι ακραίες τιμές μπορεί να είναι σφάλματα εισαγωγής ή πραγματικές ακραίες παρατηρήσεις. Η σωστή αντιμετώπισή τους απαιτεί κρίση.

9.1 Μέθοδος IQR (Interquartile Range)

def detect_outliers_iqr(df, column, factor=1.5):
    Q1 = df[column].quantile(0.25)
    Q3 = df[column].quantile(0.75)
    IQR = Q3 - Q1
    lower_bound = Q1 - factor * IQR
    upper_bound = Q3 + factor * IQR

    outliers = df[(df[column] < lower_bound) | (df[column] > upper_bound)]
    print(f"Στήλη: {column}")
    print(f"  Q1={Q1}, Q3={Q3}, IQR={IQR}")
    print(f"  Όρια: [{lower_bound}, {upper_bound}]")
    print(f"  Ακραίες τιμές: {len(outliers)}")

    return outliers

# Εφαρμογή
outliers_age = detect_outliers_iqr(df, "age")
outliers_salary = detect_outliers_iqr(df, "salary")

9.2 Μέθοδος Z-Score

from scipy import stats

def detect_outliers_zscore(df, column, threshold=3):
    z_scores = np.abs(stats.zscore(df[column].dropna()))
    outlier_mask = z_scores > threshold
    outlier_indices = df[column].dropna().index[outlier_mask]
    return df.loc[outlier_indices]

outliers_z = detect_outliers_zscore(df, "age")

9.3 Στρατηγικές Αντιμετώπισης

# Στρατηγική 1: Κοπή (Capping/Winsorization)
def cap_outliers(df, column, lower_percentile=0.05, upper_percentile=0.95):
    lower = df[column].quantile(lower_percentile)
    upper = df[column].quantile(upper_percentile)
    df[column] = df[column].clip(lower=lower, upper=upper)
    return df

df = cap_outliers(df, "age")

# Στρατηγική 2: Αντικατάσταση με NaN και μετά imputation
df.loc[df["age"] < 0, "age"] = np.nan
df.loc[df["age"] > 120, "age"] = np.nan

# Στρατηγική 3: Λογαριθμικός μετασχηματισμός (για δεξιά κλίση)
# df["salary_log"] = np.log1p(df["salary"])

Η σωστή στρατηγική εξαρτάται πάντα από τη φύση των δεδομένων. Αν μια ηλικία είναι -5 ή 150, πρόκειται σίγουρα για σφάλμα. Αν όμως ένας μισθός είναι 50.000 σε ένα dataset με μέσο 3.000, μπορεί να είναι αληθινός — αξίζει να ερευνήσετε πριν τον αφαιρέσετε.

10. Method Chaining με pipe() — Ο Σύγχρονος Τρόπος Καθαρισμού

Η μέθοδος .pipe() του pandas σας επιτρέπει να δημιουργείτε αλυσίδες μετασχηματισμών που σχεδόν διαβάζονται σαν πεζό κείμενο. Κατά τη γνώμη μου, είναι ένα από τα πιο ισχυρά μοτίβα στη σύγχρονη ανάλυση δεδομένων:

# Ορισμός μικρών, επαναχρησιμοποιήσιμων συναρτήσεων καθαρισμού
def clean_column_names(df):
    df = df.copy()
    df.columns = (df.columns
        .str.strip()
        .str.lower()
        .str.replace(" ", "_", regex=False)
    )
    return df

def remove_duplicates(df, subset=None):
    return df.drop_duplicates(subset=subset, keep="first")

def standardize_text_column(df, column):
    df = df.copy()
    df[column] = df[column].str.strip().str.title()
    return df

def convert_to_numeric(df, columns):
    df = df.copy()
    for col in columns:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    return df

def filter_valid_range(df, column, min_val, max_val):
    df = df.copy()
    mask = (df[column] >= min_val) & (df[column] <= max_val)
    df.loc[~mask, column] = np.nan
    return df

# Σύνθεση pipeline με .pipe()
df_clean = (pd.read_csv("raw_data.csv")
    .pipe(clean_column_names)
    .pipe(remove_duplicates, subset=["customer_name"])
    .pipe(standardize_text_column, column="city")
    .pipe(convert_to_numeric, columns=["salary", "age"])
    .pipe(filter_valid_range, column="age", min_val=0, max_val=120)
)

print(df_clean.head())

Τα πλεονεκτήματα αυτής της προσέγγισης;

  • Αναγνωσιμότητα: Κάθε βήμα έχει σαφή περιγραφή
  • Δοκιμασιμότητα: Κάθε συνάρτηση μπορεί να δοκιμαστεί ανεξάρτητα
  • Επαναχρησιμοποίηση: Οι ίδιες συναρτήσεις δουλεύουν σε διαφορετικά datasets
  • Ιχνηλασιμότητα: Εύκολα προσθέτετε ή αφαιρείτε βήματα

11. Pyjanitor — Λιγότερος Κώδικας, Καθαρότερα Δεδομένα

Η βιβλιοθήκη pyjanitor επεκτείνει το pandas με μεθόδους που κάνουν τον καθαρισμό ακόμα πιο εκφραστικό. Βασίζεται στο πακέτο janitor της R και, ειλικρινά, αν δεν την έχετε δοκιμάσει, χάνετε:

pip install pyjanitor
import janitor

# Χωρίς pyjanitor — πολύ κώδικας
df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_")
df = df.drop_duplicates()
df = df.dropna(subset=["customer_name"])
df = df.rename(columns={"old_name": "new_name"})

# Με pyjanitor — συνοπτικό και εκφραστικό
df_clean = (pd.read_csv("raw_data.csv")
    .clean_names()                          # Τυποποίηση ονομάτων στηλών
    .remove_empty()                         # Αφαίρεση κενών γραμμών/στηλών
    .rename_column("old_name", "new_name")  # Μετονομασία στήλης
    .filter_on("age > 0 and age < 120")     # Φίλτρο εγκυρότητας
    .transform_column("city", str.title)    # Μετασχηματισμός στήλης
)

Μερικές από τις πιο χρήσιμες μεθόδους:

  • .clean_names() — Τυποποίηση ονομάτων (πεζά, underscores, αφαίρεση ειδικών χαρακτήρων)
  • .remove_empty() — Αφαίρεση εντελώς κενών γραμμών και στηλών
  • .find_replace() — Αντικατάσταση τιμών σε πολλαπλές στήλες
  • .transform_column() — Εφαρμογή μετασχηματισμού σε μία στήλη
  • .filter_on() — Φιλτράρισμα με εκφράσεις query-style
  • .add_column() — Προσθήκη νέας στήλης μέσα στην αλυσίδα

Ένα μεγάλο πλεονέκτημα; Πλήρης συμβατότητα με τα υπάρχοντα pandas DataFrames. Δεν χρειάζεται να αλλάξετε δομή κώδικα — απλώς «κουμπώνει» πάνω στο pandas που ήδη ξέρετε.

12. Αυτοματοποιημένα Pipelines με Scikit-Learn

Όταν ο καθαρισμός δεδομένων γίνεται κομμάτι ενός ML workflow, τα Pipelines και οι ColumnTransformers του scikit-learn γίνονται ανεκτίμητα εργαλεία. Ας δούμε πώς:

12.1 Βασικό Pipeline

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer

# Ορισμός αριθμητικών και κατηγορικών στηλών
numeric_features = ["age", "salary"]
categorical_features = ["city", "category"]

# Pipeline για αριθμητικά χαρακτηριστικά
numeric_pipeline = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler())
])

# Pipeline για κατηγορικά χαρακτηριστικά
categorical_pipeline = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
])

# Σύνθεση με ColumnTransformer
preprocessor = ColumnTransformer(transformers=[
    ("num", numeric_pipeline, numeric_features),
    ("cat", categorical_pipeline, categorical_features)
])

# Εφαρμογή
X_transformed = preprocessor.fit_transform(df)
print(f"Σχήμα εξόδου: {X_transformed.shape}")

# Ανάκτηση ονομάτων στηλών
feature_names = (
    numeric_features +
    list(preprocessor.named_transformers_["cat"]
         .named_steps["encoder"]
         .get_feature_names_out(categorical_features))
)
print(f"Χαρακτηριστικά: {feature_names}")

12.2 Ολοκληρωμένο Pipeline με Μοντέλο

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

# Πλήρες pipeline: προεπεξεργασία + μοντέλο
full_pipeline = Pipeline(steps=[
    ("preprocessor", preprocessor),
    ("classifier", RandomForestClassifier(n_estimators=100, random_state=42))
])

# Cross-validation — ΜΕ αυτόματη προεπεξεργασία!
# Αποτρέπει data leakage γιατί η προεπεξεργασία
# εφαρμόζεται χωριστά σε κάθε fold
# scores = cross_val_score(full_pipeline, X, y, cv=5, scoring="accuracy")
# print(f"Ακρίβεια: {scores.mean():.3f} +/- {scores.std():.3f}")

12.3 Custom Transformer

Θέλετε κάτι πιο εξειδικευμένο; Μπορείτε να φτιάξετε τους δικούς σας transformers:

from sklearn.base import BaseEstimator, TransformerMixin

class OutlierClipper(BaseEstimator, TransformerMixin):

    def __init__(self, lower_percentile=0.05, upper_percentile=0.95):
        self.lower_percentile = lower_percentile
        self.upper_percentile = upper_percentile

    def fit(self, X, y=None):
        self.lower_ = np.percentile(X, self.lower_percentile * 100, axis=0)
        self.upper_ = np.percentile(X, self.upper_percentile * 100, axis=0)
        return self

    def transform(self, X):
        return np.clip(X, self.lower_, self.upper_)

class DateFeatureExtractor(BaseEstimator, TransformerMixin):

    def fit(self, X, y=None):
        return self

    def transform(self, X):
        X = pd.DataFrame(X)
        result = pd.DataFrame()
        for col in X.columns:
            dates = pd.to_datetime(X[col], errors="coerce")
            result[f"{col}_year"] = dates.dt.year
            result[f"{col}_month"] = dates.dt.month
            result[f"{col}_dayofweek"] = dates.dt.dayofweek
        return result.values

# Χρήση στο pipeline
enhanced_numeric_pipeline = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="median")),
    ("outlier_clipper", OutlierClipper()),
    ("scaler", StandardScaler())
])

Αυτό που κάνει τα pipelines πραγματικά ξεχωριστά είναι ότι αποτρέπουν τη διαρροή δεδομένων (data leakage): κάθε μετασχηματισμός εκπαιδεύεται μόνο στα training data και εφαρμόζεται αυτόματα με τις ίδιες παραμέτρους στα test data.

13. Οπτικοποίηση για Καθαρισμό Δεδομένων

Μερικές φορές ένα γράφημα αξίζει χίλιες γραμμές df.info(). Η οπτικοποίηση βοηθάει πολύ στον εντοπισμό προβλημάτων ποιότητας:

import matplotlib.pyplot as plt
import seaborn as sns

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 1. Χάρτης ελλιπών τιμών (missingno-style)
missing_data = df.isnull().astype(int)
sns.heatmap(missing_data, cbar=True, cmap="YlOrRd", ax=axes[0, 0])
axes[0, 0].set_title("Χάρτης Ελλιπών Τιμών")

# 2. Box plot για εντοπισμό outliers
df.boxplot(column=["age", "salary"], ax=axes[0, 1])
axes[0, 1].set_title("Κατανομή Αριθμητικών Μεταβλητών")

# 3. Κατανομή κατηγοριών
df["category"].value_counts().plot(kind="bar", ax=axes[1, 0], color="steelblue")
axes[1, 0].set_title("Κατανομή Κατηγοριών")
axes[1, 0].tick_params(axis="x", rotation=45)

# 4. Ιστόγραμμα ηλικίας
df["age"].hist(bins=20, ax=axes[1, 1], color="coral", edgecolor="black")
axes[1, 1].set_title("Κατανομή Ηλικίας")

plt.tight_layout()
plt.savefig("data_quality_report.png", dpi=150)
plt.show()

Bonus tip: Η βιβλιοθήκη missingno προσφέρει εξαιρετικές οπτικοποιήσεις αποκλειστικά για ελλιπείς τιμές:

import missingno as msno

msno.matrix(df)         # Matrix plot
msno.bar(df)            # Bar chart
msno.heatmap(df)        # Συσχέτιση ελλιπών τιμών
msno.dendrogram(df)     # Dendrogram

14. Πρακτικό Παράδειγμα: Ολοκληρωμένο Pipeline Καθαρισμού

Ωραία, ας βάλουμε τα πάντα μαζί σε ένα ρεαλιστικό σενάριο. Φανταστείτε ότι λαμβάνετε καθημερινά δεδομένα πωλήσεων από πολλαπλές πηγές (κάτι που συμβαίνει πιο συχνά απ' ό,τι νομίζετε):

import pandas as pd
import numpy as np
from datetime import datetime

class SalesDataCleaner:

    def __init__(self, config=None):
        self.config = config or {}
        self.cleaning_log = []

    def log(self, message, rows_affected=0):
        self.cleaning_log.append({
            "timestamp": datetime.now().isoformat(),
            "action": message,
            "rows_affected": rows_affected
        })

    def clean(self, df):
        initial_rows = len(df)
        df = (df
            .pipe(self._standardize_columns)
            .pipe(self._remove_exact_duplicates)
            .pipe(self._handle_missing_values)
            .pipe(self._fix_data_types)
            .pipe(self._validate_ranges)
            .pipe(self._standardize_categories)
        )
        final_rows = len(df)
        self.log(f"Pipeline completed: {initial_rows} -> {final_rows} rows")
        return df

    def _standardize_columns(self, df):
        df = df.copy()
        df.columns = (df.columns
            .str.strip()
            .str.lower()
            .str.replace(r"[^a-z0-9_]", "_", regex=True)
            .str.replace(r"_+", "_", regex=True)
            .str.strip("_")
        )
        self.log("Standardized column names")
        return df

    def _remove_exact_duplicates(self, df):
        n_before = len(df)
        df = df.drop_duplicates()
        n_removed = n_before - len(df)
        self.log(f"Removed duplicates", n_removed)
        return df

    def _handle_missing_values(self, df):
        df = df.copy()
        placeholders = ["N/A", "n/a", "-", "", "null", "None", "UNKNOWN"]
        df = df.replace(placeholders, np.nan)
        n_missing = df.isnull().sum().sum()
        self.log(f"Found {n_missing} missing values")
        return df

    def _fix_data_types(self, df):
        df = df.copy()
        for col in df.select_dtypes(include="object").columns:
            try:
                converted = pd.to_numeric(df[col], errors="coerce")
                if converted.notna().sum() > len(df) * 0.5:
                    df[col] = converted
                    self.log(f"Converted {col} to numeric")
            except (ValueError, TypeError):
                pass
        return df

    def _validate_ranges(self, df):
        df = df.copy()
        ranges = self.config.get("valid_ranges", {})
        for col, (min_val, max_val) in ranges.items():
            if col in df.columns:
                invalid = ((df[col] < min_val) | (df[col] > max_val)).sum()
                df.loc[(df[col] < min_val) | (df[col] > max_val), col] = np.nan
                self.log(f"Invalidated out-of-range in {col}", invalid)
        return df

    def _standardize_categories(self, df):
        df = df.copy()
        cat_cols = self.config.get("categorical_columns", [])
        for col in cat_cols:
            if col in df.columns:
                df[col] = df[col].str.strip().str.lower().str.capitalize()
        self.log("Standardized categories")
        return df

    def get_report(self):
        print("=" * 60)
        print("DATA CLEANING REPORT")
        print("=" * 60)
        for entry in self.cleaning_log:
            affected = f" ({entry['rows_affected']} rows)" if entry["rows_affected"] else ""
            print(f"  * {entry['action']}{affected}")
        print("=" * 60)


# Χρήση
config = {
    "valid_ranges": {
        "age": (0, 120),
        "salary": (0, 500000),
        "quantity": (1, 10000)
    },
    "categorical_columns": ["category", "city", "status"]
}

cleaner = SalesDataCleaner(config=config)
df_clean = cleaner.clean(df)
cleaner.get_report()

15. Βέλτιστες Πρακτικές και Συμβουλές

Μετά από πολλές ώρες debugging (και, ναι, μερικά λάθη που θα μπορούσα να είχα αποφύγει), αυτές είναι οι αρχές που έχω βρει πιο πολύτιμες:

15.1 Πάντα Κρατάτε Αντίγραφο των Αρχικών Δεδομένων

# Αποθηκεύστε τα αρχικά δεδομένα
df_raw = pd.read_csv("data.csv")
df = df_raw.copy()  # Εργαστείτε στο αντίγραφο

# Ή ακόμα καλύτερα: χρησιμοποιήστε version control
# raw_data/ -> cleaned_data/ -> processed_data/

15.2 Τεκμηριώστε Κάθε Βήμα

# Δημιουργία log αλλαγών
cleaning_steps = []

n_before = len(df)
df = df.dropna(subset=["customer_id"])
n_after = len(df)
cleaning_steps.append(
    f"Removed rows without customer_id: {n_before - n_after} rows"
)

# Αποθήκευση log
with open("cleaning_log.txt", "w") as f:
    f.write("\n".join(cleaning_steps))

15.3 Αυτοματοποιήστε τον Έλεγχο Ποιότητας

def validate_data(df, rules):
    issues = []

    # Έλεγχος ελλιπών τιμών
    missing = df.isnull().sum()
    for col, count in missing[missing > 0].items():
        pct = count / len(df) * 100
        if pct > rules.get("max_missing_pct", 5):
            issues.append(f"WARNING: {col} has {pct:.1f}% missing values")

    # Έλεγχος διπλοτύπων
    n_dupes = df.duplicated().sum()
    if n_dupes > 0:
        issues.append(f"WARNING: {n_dupes} duplicate rows")

    # Έλεγχος τύπων δεδομένων
    for col, expected_type in rules.get("expected_types", {}).items():
        if col in df.columns and not pd.api.types.is_dtype_equal(
            df[col].dtype, expected_type
        ):
            issues.append(
                f"WARNING: {col} is {df[col].dtype}, expected {expected_type}"
            )

    if issues:
        print("\n".join(issues))
    else:
        print("All checks passed!")

    return len(issues) == 0

# Εφαρμογή
rules = {
    "max_missing_pct": 10,
    "expected_types": {"age": "Int64", "salary": "float64"}
}
validate_data(df_clean, rules)

15.4 Χρησιμοποιήστε Nullable Τύπους στο Pandas 3.0

# Παραδοσιακό πρόβλημα: ακέραιη στήλη με NaN γίνεται float
df = pd.DataFrame({"id": [1, 2, None, 4]})
print(df["id"].dtype)  # float64 (στο pandas 2.x)

# Λύση με nullable types
df["id"] = df["id"].astype("Int64")  # Κεφαλαίο I!
print(df["id"].dtype)  # Int64
print(df["id"])
# 0       1
# 1       2
# 2    
# 3       4

# Στο pandas 3.0 αυτή η συμπεριφορά είναι πιο εύκολα προσβάσιμη
# χάρη στην ενσωμάτωση PyArrow

15.5 Προσοχή στη Διαρροή Δεδομένων (Data Leakage)

from sklearn.model_selection import train_test_split

# ΛΑΝΘΑΣΜΕΝΟ: Κανονικοποίηση πριν τον διαχωρισμό
# scaler.fit_transform(X)  # Χρησιμοποιεί πληροφορία από test data!
# X_train, X_test = train_test_split(X)

# ΣΩΣΤΟ: Διαχωρισμός ΠΡΩΤΑ, κανονικοποίηση ΜΕΤΑ
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# scaler.fit(X_train)  # Μαθαίνει μόνο από train
# X_train = scaler.transform(X_train)
# X_test = scaler.transform(X_test)  # Εφαρμόζει τις ίδιες παραμέτρους

# ΑΚΟΜΑ ΚΑΛΥΤΕΡΟ: Pipeline (αυτόματη αποφυγή leakage)
# pipeline.fit(X_train, y_train)
# pipeline.score(X_test, y_test)

16. Σύγκριση Εργαλείων: Ποιο να Επιλέξετε;

Να μια γρήγορη σύγκριση των εργαλείων που εξετάσαμε, ώστε να ξέρετε πότε να χρησιμοποιήσετε το καθένα:

Εργαλείο Καταλληλότητα Πλεονεκτήματα Μειονεκτήματα
Pandas (βασικό) Κάθε εργασία καθαρισμού Ευελιξία, τεράστιο οικοσύστημα Μπορεί να γίνει verbose
Pandas .pipe() Modular pipelines Αναγνωσιμότητα, επαναχρησιμοποίηση Απαιτεί σωστό σχεδιασμό συναρτήσεων
Pyjanitor Γρήγορος, εκφραστικός καθαρισμός Ελάχιστος κώδικας, method chaining Επιπλέον dependency
Scikit-Learn Pipeline ML preprocessing Αποτρέπει data leakage, αυτοματοποίηση Πιο σύνθετη αρχική ρύθμιση

Συμπέρασμα

Ο καθαρισμός δεδομένων δεν είναι ένα «βαρετό» βήμα που πρέπει να ξεπεράσετε. Είναι η βάση πάνω στην οποία χτίζεται κάθε αξιόπιστη ανάλυση και κάθε ακριβές ML μοντέλο. Χωρίς καθαρά δεδομένα, ακόμα κι ο πιο εξελιγμένος αλγόριθμος θα παράγει αναξιόπιστα αποτελέσματα.

Σε αυτόν τον οδηγό καλύψαμε:

  • Τεχνικές αναγνώρισης και χειρισμού ελλιπών τιμών, διπλοτύπων και ακραίων τιμών
  • Τυποποίηση κειμένου, κατηγοριών και τύπων δεδομένων
  • Σύγχρονα μοτίβα method chaining με .pipe() και pyjanitor
  • Αυτοματοποιημένα pipelines με scikit-learn για αποτροπή data leakage
  • Custom transformers και ολοκληρωμένα παραδείγματα καθαρισμού
  • Βέλτιστες πρακτικές που ισχύουν σε κάθε project

Η πρότασή μου; Ξεκινήστε με τα βασικά του pandas, υιοθετήστε σταδιακά το method chaining με .pipe(), και μεταβείτε σε scikit-learn Pipelines όταν δουλεύετε με ML μοντέλα. Η εργαλειοθήκη σας θα μεγαλώνει μαζί με την εμπειρία σας — και, πιστέψτε με, η ικανοποίηση από ένα πεντακάθαρο dataset αξίζει κάθε λεπτό που επενδύσατε.

Σχετικά με τον Συγγραφέα Editorial Team

Our team of expert writers and editors.