Spaces:
Sleeping
Sleeping
| """Learner-level feature engineering.""" | |
| from __future__ import annotations | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| from .config import ( | |
| ACTIVITY_TYPES_TOP_N, | |
| DEFAULT_ACTIVITY_TYPES, | |
| PRESENTATION_LENGTH, | |
| USE_REGION_ONEHOT, | |
| ) | |
| AGE_ORD = {"0-35": 0, "35-55": 1, "55<=": 2} | |
| EDU_ORD = { | |
| "No Formal quals": 0, | |
| "Lower Than A Level": 1, | |
| "A Level or Equivalent": 2, | |
| "HE Qualification": 3, | |
| "Post Graduate Qualification": 4, | |
| } | |
| IMD_ORD = {f"{i * 10}-{(i + 1) * 10}%": i for i in range(10)} | |
| COLLAB_ACTIVITY_TYPES = {"forumng", "oucollaborate", "ouwiki"} | |
| def _safe_slug(value: str) -> str: | |
| value = value.lower().strip() | |
| value = re.sub(r"[^a-z0-9]+", "_", value) | |
| return value.strip("_") | |
| def demographic_features(info: pd.DataFrame, registration: pd.DataFrame | None = None) -> pd.DataFrame: | |
| df = info.copy() | |
| df["age_band_ord"] = df["age_band"].map(AGE_ORD) | |
| imd = df["imd_band"].map(IMD_ORD) | |
| df["imd_band_ord"] = imd.fillna(imd.median()) | |
| df["highest_education_ord"] = df["highest_education"].map(EDU_ORD) | |
| df["disability_bin"] = df["disability"].eq("Y").astype(int) | |
| df["gender_M"] = df["gender"].eq("M").astype(int) | |
| df["num_prev_attempts"] = pd.to_numeric(df["num_of_prev_attempts"], errors="coerce") | |
| df["studied_credits"] = pd.to_numeric(df["studied_credits"], errors="coerce") | |
| cols = [ | |
| "id_student", | |
| "age_band_ord", | |
| "imd_band_ord", | |
| "highest_education_ord", | |
| "disability_bin", | |
| "gender_M", | |
| "num_prev_attempts", | |
| "studied_credits", | |
| ] | |
| out = df[cols].copy() | |
| if registration is not None and not registration.empty: | |
| reg = registration[["id_student", "date_registration"]].copy() | |
| reg["registration_day"] = pd.to_numeric(reg["date_registration"], errors="coerce") | |
| out = out.merge(reg[["id_student", "registration_day"]], on="id_student", how="left") | |
| else: | |
| out["registration_day"] = np.nan | |
| if USE_REGION_ONEHOT: | |
| top_regions = df["region"].value_counts().head(5).index.tolist() | |
| for region in top_regions: | |
| out[f"region_{_safe_slug(region)}"] = df["region"].eq(region).astype(int).values | |
| out["region_other"] = (~df["region"].isin(top_regions)).astype(int).values | |
| return out | |
| def engagement_features( | |
| vle: pd.DataFrame, | |
| vle_meta: pd.DataFrame, | |
| presentation_length: int = PRESENTATION_LENGTH, | |
| ) -> pd.DataFrame: | |
| if vle.empty: | |
| columns = [ | |
| "id_student", | |
| "total_clicks", | |
| "active_days", | |
| "mean_clicks_per_active_day", | |
| "first_click_day", | |
| "last_click_day", | |
| "engagement_span", | |
| "click_std", | |
| "click_cv", | |
| "weekend_ratio", | |
| ] + [f"clicks_{name}" for name in DEFAULT_ACTIVITY_TYPES[:ACTIVITY_TYPES_TOP_N]] | |
| return pd.DataFrame(columns=columns) | |
| merged = vle.merge( | |
| vle_meta[["id_site", "activity_type"]].drop_duplicates("id_site"), | |
| on="id_site", | |
| how="left", | |
| ) | |
| merged["sum_click"] = pd.to_numeric(merged["sum_click"], errors="coerce").fillna(0) | |
| merged["date"] = pd.to_numeric(merged["date"], errors="coerce") | |
| merged = merged[(merged["date"] >= 0) & (merged["date"] <= presentation_length)].copy() | |
| merged["activity_type"] = merged["activity_type"].fillna("unknown") | |
| agg = merged.groupby("id_student").agg( | |
| total_clicks=("sum_click", "sum"), | |
| active_days=("date", "nunique"), | |
| first_click_day=("date", "min"), | |
| last_click_day=("date", "max"), | |
| ).reset_index() | |
| agg["mean_clicks_per_active_day"] = agg["total_clicks"] / agg["active_days"].replace(0, np.nan) | |
| agg["engagement_span"] = agg["last_click_day"] - agg["first_click_day"] | |
| daily = merged.groupby(["id_student", "date"], as_index=False)["sum_click"].sum() | |
| dispersion = daily.groupby("id_student")["sum_click"].agg(["std", "mean"]).reset_index() | |
| dispersion.columns = ["id_student", "click_std", "click_mean_daily"] | |
| dispersion["click_cv"] = dispersion["click_std"] / dispersion["click_mean_daily"].replace(0, np.nan) | |
| agg = agg.merge(dispersion[["id_student", "click_std", "click_cv"]], on="id_student", how="left") | |
| merged["weekday"] = (merged["date"].astype(int) % 7).astype(int) | |
| merged["is_weekend"] = merged["weekday"].isin([5, 6]).astype(int) | |
| weekend = ( | |
| merged.assign(weekend_clicks=merged["sum_click"] * merged["is_weekend"]) | |
| .groupby("id_student") | |
| .agg(weekend_clicks=("weekend_clicks", "sum"), total=("sum_click", "sum")) | |
| .reset_index() | |
| ) | |
| weekend["weekend_ratio"] = weekend["weekend_clicks"] / weekend["total"].replace(0, np.nan) | |
| agg = agg.merge(weekend[["id_student", "weekend_ratio"]], on="id_student", how="left") | |
| merged["is_collab"] = merged["activity_type"].isin(COLLAB_ACTIVITY_TYPES) | |
| merged["collaborative_click_component"] = np.where(merged["is_collab"], merged["sum_click"], 0) | |
| merged["forum_click_component"] = np.where(merged["activity_type"].eq("forumng"), merged["sum_click"], 0) | |
| merged["live_collab_click_component"] = np.where( | |
| merged["activity_type"].eq("oucollaborate"), | |
| merged["sum_click"], | |
| 0, | |
| ) | |
| collab = merged.groupby("id_student").agg( | |
| collaborative_clicks=("collaborative_click_component", "sum"), | |
| forum_clicks=("forum_click_component", "sum"), | |
| live_collab_clicks=("live_collab_click_component", "sum"), | |
| ).reset_index() | |
| collab_days = ( | |
| merged[merged["is_collab"]] | |
| .groupby("id_student")["date"] | |
| .nunique() | |
| .reset_index(name="collaborative_active_days") | |
| ) | |
| collab = collab.merge(collab_days, on="id_student", how="left") | |
| agg = agg.merge(collab, on="id_student", how="left") | |
| agg["collaboration_click_ratio"] = agg["collaborative_clicks"] / agg["total_clicks"].replace(0, np.nan) | |
| non_collab_activity = merged[~merged["activity_type"].isin(COLLAB_ACTIVITY_TYPES)] | |
| top_types = non_collab_activity["activity_type"].value_counts().head(ACTIVITY_TYPES_TOP_N).index.tolist() | |
| for fallback in DEFAULT_ACTIVITY_TYPES: | |
| if len(top_types) >= ACTIVITY_TYPES_TOP_N: | |
| break | |
| if fallback not in COLLAB_ACTIVITY_TYPES and fallback not in top_types: | |
| top_types.append(fallback) | |
| pivot = merged[merged["activity_type"].isin(top_types)].pivot_table( | |
| index="id_student", | |
| columns="activity_type", | |
| values="sum_click", | |
| aggfunc="sum", | |
| fill_value=0, | |
| ) | |
| pivot = pivot.reindex(columns=top_types, fill_value=0) | |
| pivot.columns = [f"clicks_{_safe_slug(col)}" for col in pivot.columns] | |
| agg = agg.merge(pivot.reset_index(), on="id_student", how="left") | |
| return agg.fillna(0) | |
| def performance_features(assessment: pd.DataFrame, assessments: pd.DataFrame) -> pd.DataFrame: | |
| if assessment.empty or assessments.empty: | |
| return pd.DataFrame( | |
| columns=[ | |
| "id_student", | |
| "mean_tma_score", | |
| "weighted_score", | |
| "n_assessments_submitted", | |
| "mean_submission_lateness", | |
| "score_trajectory_slope", | |
| "no_submissions", | |
| ] | |
| ) | |
| merged = assessment.merge( | |
| assessments[["id_assessment", "date", "weight", "assessment_type"]], | |
| on="id_assessment", | |
| how="left", | |
| ) | |
| for col in ["score", "weight", "date", "date_submitted"]: | |
| merged[col] = pd.to_numeric(merged[col], errors="coerce") | |
| merged = merged.dropna(subset=["id_student"]) | |
| merged["lateness"] = (merged["date_submitted"] - merged["date"]).clip(lower=0) | |
| def per_learner(group: pd.DataFrame) -> pd.Series: | |
| tma = group[group["assessment_type"].eq("TMA")] | |
| valid_weighted = group.dropna(subset=["score", "weight"]) | |
| weight_sum = valid_weighted["weight"].sum() | |
| valid_slope = group.dropna(subset=["date_submitted", "score"]) | |
| if len(valid_slope) >= 2 and valid_slope["date_submitted"].nunique() >= 2: | |
| slope = float(np.polyfit(valid_slope["date_submitted"], valid_slope["score"], 1)[0]) | |
| else: | |
| slope = np.nan | |
| return pd.Series( | |
| { | |
| "mean_tma_score": tma["score"].mean() if len(tma) else np.nan, | |
| "weighted_score": (valid_weighted["score"] * valid_weighted["weight"]).sum() / weight_sum | |
| if weight_sum > 0 | |
| else np.nan, | |
| "n_assessments_submitted": float(len(group)), | |
| "mean_submission_lateness": group["lateness"].mean(), | |
| "score_trajectory_slope": slope, | |
| } | |
| ) | |
| out = merged.groupby("id_student").apply(per_learner, include_groups=False).reset_index() | |
| out["no_submissions"] = 0 | |
| return out | |
| def build_feature_matrix(tables: dict[str, pd.DataFrame]) -> pd.DataFrame: | |
| demo = demographic_features(tables["info"], tables.get("registration")) | |
| engage = engagement_features(tables["vle"], tables["vle_meta"]) | |
| perf = performance_features(tables["assessment"], tables["assessments"]) | |
| matrix = demo.merge(engage, on="id_student", how="left").merge(perf, on="id_student", how="left") | |
| matrix["no_submissions"] = matrix["n_assessments_submitted"].isna().astype(int) | |
| matrix["n_assessments_submitted"] = matrix["n_assessments_submitted"].fillna(0) | |
| numeric = matrix.columns.drop("id_student") | |
| matrix[numeric] = matrix[numeric].apply(pd.to_numeric, errors="coerce") | |
| return matrix | |
| def run(tables: dict[str, pd.DataFrame]) -> pd.DataFrame: | |
| return build_feature_matrix(tables) | |