collablearn-int396 / src /features.py
Cyril-36's picture
Deploy CollabLearn Streamlit demo via Docker
d81f51d verified
Raw
History Blame Contribute Delete
9.68 kB
"""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)