"""Prepare a safe classroom feature table from the three UCI CSV files.""" from pathlib import Path import re import numpy as np import pandas as pd HERE = Path(__file__).resolve().parent RAW_DIR = HERE / "raw" OUTPUT_PATH = HERE / "malware_features.csv" MANIFEST_PATH = HERE / "feature_manifest.csv" FILES = { "benign_lab": ("staDynBenignLab.csv", 0), "vxheaven": ("staDynVxHeaven2698Lab.csv", 1), "virustotal": ("staDynVt2955Lab.csv", 1), } FORBIDDEN_NAMES = { "label", "class", "target", "malware", "is_malware", "type", "file", "filename", "file_name", "name", "hash", "md5", "sha1", "sha256", "source", "category", } def normalize_name(value: object) -> str: """Convert a source column name to a stable snake_case identifier.""" text = re.sub(r"[^0-9A-Za-z]+", "_", str(value).strip()).strip("_").lower() return text or "unnamed_feature" def normalize_columns(columns: list[object]) -> tuple[list[str], dict[str, list[str]]]: """Normalize names and disambiguate collisions by their source-column order. The UCI files intentionally contain case- or punctuation-distinct fields such as ``WORD``/``word`` and ``Import``/``__IMPORT_``. A simple lowercase snake-case conversion collapses each pair. The three source files use the same relative order for these fields, so an occurrence suffix produces stable shared names: ``word``, ``word__2``, and so on. """ counts: dict[str, int] = {} normalized: list[str] = [] originals_by_base: dict[str, list[str]] = {} for raw_column in columns: base = normalize_name(raw_column) counts[base] = counts.get(base, 0) + 1 occurrence = counts[base] unique_name = base if occurrence == 1 else f"{base}__{occurrence}" normalized.append(unique_name) originals_by_base.setdefault(base, []).append(str(raw_column)) collisions = { base: originals for base, originals in originals_by_base.items() if len(originals) > 1 } if len(normalized) != len(set(normalized)): raise ValueError("Column names are still duplicated after disambiguation.") return normalized, collisions def find_required_file(filename: str) -> Path: """Find one required CSV below data/raw, allowing an extracted subfolder.""" matches = list(RAW_DIR.rglob(filename)) if RAW_DIR.exists() else [] if len(matches) != 1: raise FileNotFoundError( f"Expected exactly one {filename!r} below {RAW_DIR}. " "Follow data/README.md and check the extracted folder structure." ) return matches[0] def load_source(source: str, filename: str, label: int) -> pd.DataFrame: """Load one source file and attach provenance columns.""" frame = pd.read_csv(find_required_file(filename), low_memory=False) normalized, collisions = normalize_columns(frame.columns.tolist()) frame.columns = normalized # Consolidate the wide table before adding provenance columns. This avoids # pandas fragmentation warnings on the 1,000+ column UCI source files. frame = frame.copy() frame.attrs["normalized_column_collisions"] = collisions frame["source"] = source frame["label"] = label return frame def validate_collision_alignment(frames: dict[str, pd.DataFrame]) -> None: """Ensure occurrence suffixes refer to the same raw fields in every source.""" collision_names = set().union(*( set(frame.attrs.get("normalized_column_collisions", {})) for frame in frames.values() )) for base in sorted(collision_names): variants = { source: tuple(frame.attrs.get("normalized_column_collisions", {}).get(base, [])) for source, frame in frames.items() } if len(set(variants.values())) != 1: raise ValueError( f"Normalized collision {base!r} is not aligned across source files: {variants}" ) if collision_names: print("Disambiguated normalized column collisions:") reference = next(iter(frames.values())).attrs["normalized_column_collisions"] for base in sorted(collision_names): generated = [base] + [f"{base}__{index}" for index in range(2, len(reference[base]) + 1)] print(f" {reference[base]} -> {generated}") def main() -> None: frames = { source: load_source(source, filename, label) for source, (filename, label) in FILES.items() } validate_collision_alignment(frames) common = set.intersection(*(set(frame.columns) for frame in frames.values())) candidate_features = sorted(common - FORBIDDEN_NAMES - {"source", "label"}) if not candidate_features: raise ValueError("No shared candidate feature columns were found.") combined_parts = [] for source, frame in frames.items(): part = frame[candidate_features].copy() part.insert(0, "source", source) part.insert(1, "label", int(FILES[source][1])) combined_parts.append(part) combined = pd.concat(combined_parts, ignore_index=True) numeric = combined[candidate_features].apply(pd.to_numeric, errors="coerce") numeric = numeric.replace([np.inf, -np.inf], np.nan) usable_ratio = numeric.notna().mean() retained = usable_ratio[usable_ratio >= 0.95].index.tolist() numeric = numeric[retained] non_constant = numeric.nunique(dropna=True) retained = non_constant[non_constant > 1].index.tolist() numeric = numeric[retained] if numeric.empty: raise ValueError("No usable numeric features remained after validation.") prepared = pd.concat([combined[["source", "label"]], numeric], axis=1) prepared = prepared.sample(frac=1.0, random_state=42).reset_index(drop=True) prepared.to_csv(OUTPUT_PATH, index=False) manifest = pd.DataFrame({ "feature": retained, "non_missing_ratio": numeric[retained].notna().mean().values, "unique_values": numeric[retained].nunique(dropna=True).values, "feature_family": "unclassified", }) manifest.to_csv(MANIFEST_PATH, index=False) print(f"Created {OUTPUT_PATH}") print(f"Shape: {prepared.shape}") print("Class counts:") print(prepared["label"].value_counts().sort_index()) print("Source counts:") print(prepared["source"].value_counts()) print(f"Created {MANIFEST_PATH}") if __name__ == "__main__": main()