feat(phiusiil): vendor the scoring path from the full application
Browse filesThe model was fitted on standardised inputs, so reproducing its
predictions needs the same preprocessing code and not merely the same
recorded numbers.
Vendoring rather than depending on the full application keeps the page
fetcher and its SSRF guard out of a service that must never open a socket,
and leaves this repository installable on its own.
- phiusiil/__init__.py +19 -0
- phiusiil/features/__init__.py +0 -0
- phiusiil/features/url_features.py +751 -0
- phiusiil/models/__init__.py +0 -0
- phiusiil/models/base.py +56 -0
- phiusiil/models/knn_scratch.py +183 -0
- phiusiil/preprocess/__init__.py +0 -0
- phiusiil/preprocess/scaler.py +97 -0
- phiusiil/preprocess/stats.py +177 -0
- phiusiil/preprocess/transformer.py +489 -0
- phiusiil/schema.py +332 -0
- pyproject.toml +31 -0
phiusiil/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""phiusiil -- the serving subset of the PhiUSIIL phishing URL classifier.
|
| 2 |
+
|
| 3 |
+
One trained model, the fitted preprocessing state it needs, and nothing else. The module
|
| 4 |
+
boundary rules that matter here:
|
| 5 |
+
|
| 6 |
+
1. ``features/`` is pure: no network, no fitted state. It turns raw values into raw
|
| 7 |
+
values, emitting ``NaN`` for anything it cannot determine.
|
| 8 |
+
2. ``preprocess/`` owns all fitted state. ``features/`` produces ``NaN``s; ``preprocess/``
|
| 9 |
+
decides what they become, reading every number from persisted state and computing none
|
| 10 |
+
of them at serving time.
|
| 11 |
+
|
| 12 |
+
There is no fetch layer and no feature extractor for live URLs. This package scores a
|
| 13 |
+
feature row that someone else produced.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
__version__ = "1.0.0"
|
| 17 |
+
ARTIFACT_SCHEMA_VERSION = "v1"
|
| 18 |
+
|
| 19 |
+
__all__ = ["ARTIFACT_SCHEMA_VERSION", "__version__"]
|
phiusiil/features/__init__.py
ADDED
|
File without changes
|
phiusiil/features/url_features.py
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""URL-derived features, ported from the original notebook.
|
| 2 |
+
|
| 3 |
+
PORT CONTRACT
|
| 4 |
+
=============
|
| 5 |
+
|
| 6 |
+
The 26 ``fill_*`` functions below are ported **verbatim**. Every regex, every threshold,
|
| 7 |
+
every edge case and every quirk is preserved exactly. Only three changes are permitted:
|
| 8 |
+
|
| 9 |
+
1. Dropping the trailing demonstration statements each notebook cell appended.
|
| 10 |
+
2. Dropping ``print()`` calls.
|
| 11 |
+
3. Threading fitted statistics in as explicit keyword parameters instead of computing
|
| 12 |
+
them inline from the input frame.
|
| 13 |
+
|
| 14 |
+
No improvements. No bug fixes. No renaming. Where a function looks wrong it is still
|
| 15 |
+
ported as-is and the observation is recorded below rather than repaired.
|
| 16 |
+
|
| 17 |
+
The severity is deliberate: these functions define what the training data *means*.
|
| 18 |
+
Changing one silently changes the feature distribution the models were fitted on, and no
|
| 19 |
+
metric would reveal it -- the numbers would simply be describing something else. The
|
| 20 |
+
fidelity tests run these implementations and a frozen copy of the originals side by side
|
| 21 |
+
over a 2,000-row sample and assert exact frame equality, with no tolerance.
|
| 22 |
+
|
| 23 |
+
Change 3 is the one that matters for correctness. ``fill_tld_legitimate_prob`` originally
|
| 24 |
+
computed its own skew, median/mean and per-TLD group means from whatever frame it was
|
| 25 |
+
handed. At training time that frame is 112,323 rows; at serving time it would be a single
|
| 26 |
+
row, so the same URL would get a different feature value depending on what it was batched
|
| 27 |
+
with. Passing the statistics in makes the function a pure row-wise mapping, which is what
|
| 28 |
+
makes single-row inference equal batch inference.
|
| 29 |
+
|
| 30 |
+
PRESERVED DEFECTS
|
| 31 |
+
=================
|
| 32 |
+
|
| 33 |
+
``detect_advanced_obfuscation`` rule 4 tests whether the *reversed* URL matches
|
| 34 |
+
``^[a-zA-Z0-9.\\-]+$``. A string matches that pattern exactly when its reversal does, so
|
| 35 |
+
the rule fires for any URL consisting solely of letters, digits, dots and hyphens --
|
| 36 |
+
including every bare domain with no scheme. This is almost certainly not what "reversed
|
| 37 |
+
strings" was meant to detect. Preserved.
|
| 38 |
+
|
| 39 |
+
``fill_has_obfuscation`` ends with ``.apply(lambda x: 1 if x == True else 0)``, which
|
| 40 |
+
rewrites the whole column rather than only the filled cells. Preserved.
|
| 41 |
+
|
| 42 |
+
The skew threshold here is ``abs(skew) > 1``; the numeric imputer uses ``abs(skew) > 3``.
|
| 43 |
+
The two are genuinely different and both are preserved.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
from __future__ import annotations
|
| 47 |
+
|
| 48 |
+
import base64
|
| 49 |
+
import re
|
| 50 |
+
from typing import Any
|
| 51 |
+
from urllib.parse import urlparse
|
| 52 |
+
|
| 53 |
+
import numpy as np
|
| 54 |
+
import pandas as pd
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def scalar_float(value: Any) -> float:
|
| 58 |
+
"""Coerce a pandas reduction result to a plain float.
|
| 59 |
+
|
| 60 |
+
Reductions such as ``.skew()`` and ``.median()`` are typed as a wide union covering
|
| 61 |
+
every dtype a Series might hold. On the numeric columns here the value is always a
|
| 62 |
+
float; this narrows it so arithmetic and comparisons type-check, and it is an identity
|
| 63 |
+
on the values that actually occur.
|
| 64 |
+
"""
|
| 65 |
+
return float(value)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Fit-time statistic
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def calculate_char_prob(df: pd.DataFrame, url_col: str) -> dict[str, float]:
|
| 74 |
+
"""Corpus-wide alphanumeric character frequency table.
|
| 75 |
+
|
| 76 |
+
This is a *fit-time* statistic. It is computed once over the training split and
|
| 77 |
+
thereafter passed into :func:`fill_url_char_prob` as data. It must never be recomputed
|
| 78 |
+
at serving time -- a single-row corpus would give every character in that one URL a
|
| 79 |
+
probability of roughly 1/len(url), which has nothing to do with the training
|
| 80 |
+
distribution the models learned against.
|
| 81 |
+
"""
|
| 82 |
+
char_count: dict[str, int] = {}
|
| 83 |
+
total_chars = 0
|
| 84 |
+
for url in df[url_col].dropna():
|
| 85 |
+
for char in url.lower():
|
| 86 |
+
if char.isalnum(): # Only consider alphanumeric characters
|
| 87 |
+
char_count[char] = char_count.get(char, 0) + 1
|
| 88 |
+
total_chars += 1
|
| 89 |
+
return {char: count / total_chars for char, count in char_count.items()}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
# The 26 fill_* functions
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def fill_url_length(
|
| 98 |
+
data: pd.DataFrame, url_col: str = "URL", url_length_col: str = "URLLength"
|
| 99 |
+
) -> pd.DataFrame:
|
| 100 |
+
data[url_length_col] = data.apply(
|
| 101 |
+
lambda row: len(str(row[url_col]))
|
| 102 |
+
if pd.isnull(row[url_length_col]) and pd.notnull(row[url_col])
|
| 103 |
+
else row[url_length_col],
|
| 104 |
+
axis=1,
|
| 105 |
+
)
|
| 106 |
+
return data
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def fill_domain(
|
| 110 |
+
data: pd.DataFrame, url_col: str = "URL", domain_col: str = "Domain"
|
| 111 |
+
) -> pd.DataFrame:
|
| 112 |
+
def get_domain(url: Any) -> Any:
|
| 113 |
+
return urlparse(url).netloc if pd.notnull(url) else None
|
| 114 |
+
|
| 115 |
+
data[domain_col] = data.apply(
|
| 116 |
+
lambda row: get_domain(row[url_col])
|
| 117 |
+
if pd.isnull(row[domain_col]) and pd.notnull(row[url_col])
|
| 118 |
+
else row[domain_col],
|
| 119 |
+
axis=1,
|
| 120 |
+
)
|
| 121 |
+
return data
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def fill_domain_length(
|
| 125 |
+
data: pd.DataFrame, domain_col: str = "Domain", domain_length_col: str = "DomainLength"
|
| 126 |
+
) -> pd.DataFrame:
|
| 127 |
+
data[domain_length_col] = data.apply(
|
| 128 |
+
lambda row: len(str(row[domain_col]))
|
| 129 |
+
if pd.isnull(row[domain_length_col]) and pd.notnull(row[domain_col])
|
| 130 |
+
else row[domain_length_col],
|
| 131 |
+
axis=1,
|
| 132 |
+
)
|
| 133 |
+
return data
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def fill_is_domain_ip(
|
| 137 |
+
data: pd.DataFrame, domain_col: str = "Domain", is_domain_ip_col: str = "IsDomainIP"
|
| 138 |
+
) -> pd.DataFrame:
|
| 139 |
+
def is_ipaddress(domain: Any) -> bool:
|
| 140 |
+
if pd.notnull(domain):
|
| 141 |
+
ip_pattern = r"^(\d{1,3}\.){3}\d{1,3}$"
|
| 142 |
+
if re.match(ip_pattern, domain):
|
| 143 |
+
parts = domain.split(".")
|
| 144 |
+
return all(0 <= int(part) <= 255 for part in parts)
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
data[is_domain_ip_col] = data.apply(
|
| 148 |
+
lambda row: (1 if is_ipaddress(row[domain_col]) else 0)
|
| 149 |
+
if pd.isnull(row[is_domain_ip_col]) and pd.notnull(row[domain_col])
|
| 150 |
+
else row[is_domain_ip_col],
|
| 151 |
+
axis=1,
|
| 152 |
+
)
|
| 153 |
+
return data
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def fill_tld(data: pd.DataFrame, domain_col: str = "Domain", tld_col: str = "TLD") -> pd.DataFrame:
|
| 157 |
+
def generate_tld(domain: Any) -> Any:
|
| 158 |
+
if pd.notnull(domain):
|
| 159 |
+
parts = domain.split(".")
|
| 160 |
+
if len(parts) > 1:
|
| 161 |
+
return parts[-1]
|
| 162 |
+
return np.nan
|
| 163 |
+
|
| 164 |
+
data[tld_col] = data.apply(
|
| 165 |
+
lambda row: generate_tld(row[domain_col]) if pd.isnull(row[tld_col]) else row[tld_col],
|
| 166 |
+
axis=1,
|
| 167 |
+
)
|
| 168 |
+
return data
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def fill_char_continuation_rate(
|
| 172 |
+
data: pd.DataFrame, url_col: str = "URL", char_rate_col: str = "CharContinuationRate"
|
| 173 |
+
) -> pd.DataFrame:
|
| 174 |
+
def generate_char_continuation_rate(url: Any) -> Any:
|
| 175 |
+
if pd.notnull(url):
|
| 176 |
+
sequences = re.findall(r"[a-zA-Z0-9_]+", url)
|
| 177 |
+
total_sequence_length = sum(len(seq) for seq in sequences)
|
| 178 |
+
total_url_length = len(url)
|
| 179 |
+
return total_sequence_length / total_url_length if total_url_length > 0 else np.nan
|
| 180 |
+
return np.nan
|
| 181 |
+
|
| 182 |
+
data[char_rate_col] = data.apply(
|
| 183 |
+
lambda row: generate_char_continuation_rate(row[url_col])
|
| 184 |
+
if pd.isnull(row[char_rate_col])
|
| 185 |
+
else row[char_rate_col],
|
| 186 |
+
axis=1,
|
| 187 |
+
)
|
| 188 |
+
return data
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def fill_tld_legitimate_prob(
|
| 192 |
+
data: pd.DataFrame,
|
| 193 |
+
*,
|
| 194 |
+
tld_prob_mean: pd.Series,
|
| 195 |
+
global_fill_value: float,
|
| 196 |
+
tld_col: str = "TLD",
|
| 197 |
+
tld_prob_col: str = "TLDLegitimateProb",
|
| 198 |
+
is_domain_ip_col: str = "IsDomainIP",
|
| 199 |
+
) -> pd.DataFrame:
|
| 200 |
+
"""Fill ``TLDLegitimateProb``.
|
| 201 |
+
|
| 202 |
+
``tld_prob_mean`` (per-TLD group means) and ``global_fill_value`` (the median if
|
| 203 |
+
``abs(skew) > 1``, else the mean) are computed at fit time by
|
| 204 |
+
:func:`compute_tld_prob_statistics` and passed in. The original computed both inside
|
| 205 |
+
this function from its argument frame; the four-branch row rule below is otherwise
|
| 206 |
+
byte-identical to it.
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
def fill_tld_legit_prob(row: Any) -> Any:
|
| 210 |
+
if pd.isnull(row[tld_prob_col]):
|
| 211 |
+
if row[is_domain_ip_col] == 1:
|
| 212 |
+
return 0
|
| 213 |
+
if pd.notnull(row[tld_col]) and row[tld_col] in tld_prob_mean.index:
|
| 214 |
+
return tld_prob_mean[row[tld_col]]
|
| 215 |
+
if pd.isnull(row[tld_col]) and pd.isnull(row[is_domain_ip_col]):
|
| 216 |
+
return global_fill_value
|
| 217 |
+
return row[tld_prob_col]
|
| 218 |
+
|
| 219 |
+
data[tld_prob_col] = data.apply(fill_tld_legit_prob, axis=1)
|
| 220 |
+
data[tld_prob_col] = data[tld_prob_col].fillna(global_fill_value)
|
| 221 |
+
|
| 222 |
+
return data
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def compute_tld_prob_statistics(
|
| 226 |
+
data: pd.DataFrame,
|
| 227 |
+
tld_col: str = "TLD",
|
| 228 |
+
tld_prob_col: str = "TLDLegitimateProb",
|
| 229 |
+
) -> tuple[pd.Series, float]:
|
| 230 |
+
"""Fit-time half of :func:`fill_tld_legitimate_prob`, lifted out of it verbatim.
|
| 231 |
+
|
| 232 |
+
Note the threshold: ``abs(skew) > 1`` here, against ``abs(skew) > 3`` in the numeric
|
| 233 |
+
imputer. The two are genuinely different in the original and both are preserved.
|
| 234 |
+
"""
|
| 235 |
+
skewness = scalar_float(data[tld_prob_col].skew())
|
| 236 |
+
if skewness > 1 or skewness < -1:
|
| 237 |
+
global_fill_value = scalar_float(data[tld_prob_col].median())
|
| 238 |
+
else:
|
| 239 |
+
global_fill_value = scalar_float(data[tld_prob_col].mean())
|
| 240 |
+
|
| 241 |
+
tld_prob_mean = data.groupby(tld_col)[tld_prob_col].mean()
|
| 242 |
+
return tld_prob_mean, global_fill_value
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def fill_url_char_prob(
|
| 246 |
+
data: pd.DataFrame,
|
| 247 |
+
url_col: str = "URL",
|
| 248 |
+
char_prob_col: str = "URLCharProb",
|
| 249 |
+
char_prob: dict[str, float] | None = None,
|
| 250 |
+
) -> pd.DataFrame:
|
| 251 |
+
if char_prob is None:
|
| 252 |
+
raise ValueError("Character probabilities (`char_prob`) must be provided.")
|
| 253 |
+
|
| 254 |
+
def calculate_url_char_prob(url: Any) -> Any:
|
| 255 |
+
if pd.notnull(url):
|
| 256 |
+
total_prob = sum(char_prob.get(char, 0) for char in url.lower() if char.isalnum())
|
| 257 |
+
n = len(url)
|
| 258 |
+
return total_prob / n if n > 0 else np.nan
|
| 259 |
+
return np.nan
|
| 260 |
+
|
| 261 |
+
data[char_prob_col] = data.apply(
|
| 262 |
+
lambda row: calculate_url_char_prob(row[url_col])
|
| 263 |
+
if pd.isnull(row[char_prob_col])
|
| 264 |
+
else row[char_prob_col],
|
| 265 |
+
axis=1,
|
| 266 |
+
)
|
| 267 |
+
return data
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def fill_tld_length(
|
| 271 |
+
data: pd.DataFrame, tld_col: str = "TLD", tld_length_col: str = "TLDLength"
|
| 272 |
+
) -> pd.DataFrame:
|
| 273 |
+
def calculate_tld_length(tld: Any) -> Any:
|
| 274 |
+
if pd.notnull(tld):
|
| 275 |
+
return len(str(tld))
|
| 276 |
+
return np.nan
|
| 277 |
+
|
| 278 |
+
data[tld_length_col] = data.apply(
|
| 279 |
+
lambda row: calculate_tld_length(row[tld_col])
|
| 280 |
+
if pd.isnull(row[tld_length_col])
|
| 281 |
+
else row[tld_length_col],
|
| 282 |
+
axis=1,
|
| 283 |
+
)
|
| 284 |
+
return data
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def fill_no_of_subdomains(
|
| 288 |
+
data: pd.DataFrame, domain_col: str = "Domain", subdomain_col: str = "NoOfSubDomain"
|
| 289 |
+
) -> pd.DataFrame:
|
| 290 |
+
def calculate_no_of_subdomains(domain: Any) -> Any:
|
| 291 |
+
if pd.notnull(domain):
|
| 292 |
+
parts = domain.split(".")
|
| 293 |
+
return len(parts) - 2 if len(parts) > 2 else 0
|
| 294 |
+
return np.nan
|
| 295 |
+
|
| 296 |
+
data[subdomain_col] = data.apply(
|
| 297 |
+
lambda row: calculate_no_of_subdomains(row[domain_col])
|
| 298 |
+
if pd.isnull(row[subdomain_col])
|
| 299 |
+
else row[subdomain_col],
|
| 300 |
+
axis=1,
|
| 301 |
+
)
|
| 302 |
+
return data
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def fill_has_obfuscation(
|
| 306 |
+
data: pd.DataFrame, url_col: str = "URL", obfuscation_col: str = "HasObfuscation"
|
| 307 |
+
) -> pd.DataFrame:
|
| 308 |
+
data[obfuscation_col] = data.apply(
|
| 309 |
+
lambda row: detect_advanced_obfuscation(row[url_col])
|
| 310 |
+
if pd.isnull(row[obfuscation_col]) and pd.notnull(row[url_col])
|
| 311 |
+
else row[obfuscation_col],
|
| 312 |
+
axis=1,
|
| 313 |
+
)
|
| 314 |
+
data[obfuscation_col] = data[obfuscation_col].apply(lambda x: 1 if x == True else 0) # noqa: E712
|
| 315 |
+
return data
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def detect_advanced_obfuscation(url: Any) -> int:
|
| 319 |
+
"""The five obfuscation rules, first match wins.
|
| 320 |
+
|
| 321 |
+
Rule 4 is preserved despite being near-certainly wrong: a string matches
|
| 322 |
+
``^[a-zA-Z0-9.\\-]+$`` exactly when its reversal does, so reversing accomplishes
|
| 323 |
+
nothing and the rule fires for any scheme-less bare domain.
|
| 324 |
+
"""
|
| 325 |
+
if pd.notnull(url):
|
| 326 |
+
if len(re.findall(r"[-_]", url)) > 3: # Rule 1: Too many special characters
|
| 327 |
+
return 1
|
| 328 |
+
if re.search(r"[a-zA-Z]+\d+|\d+[a-zA-Z]+", url): # Rule 2: Mixed alphanumeric patterns
|
| 329 |
+
return 1
|
| 330 |
+
if len(url) % 4 == 0 and re.match(r"^[A-Za-z0-9+/]*={0,2}$", url): # Rule 3: Base64
|
| 331 |
+
try:
|
| 332 |
+
base64.b64decode(url, validate=True)
|
| 333 |
+
return 1
|
| 334 |
+
except Exception:
|
| 335 |
+
pass
|
| 336 |
+
reversed_url = url[::-1] # Rule 4: Reversed strings
|
| 337 |
+
if re.match(r"^[a-zA-Z0-9.\-]+$", reversed_url):
|
| 338 |
+
return 1
|
| 339 |
+
if not re.search(r"[a-zA-Z]{3,}", url): # Rule 5: Randomized strings
|
| 340 |
+
return 1
|
| 341 |
+
return 0 # No obfuscation detected
|
| 342 |
+
return 0 # Missing values treated as no obfuscation
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def fill_no_of_obfuscated_characters(
|
| 346 |
+
data: pd.DataFrame, url_col: str = "URL", obf_char_col: str = "NoOfObfuscatedChar"
|
| 347 |
+
) -> pd.DataFrame:
|
| 348 |
+
def count_obfuscated_characters(url: Any) -> Any:
|
| 349 |
+
if pd.notnull(url):
|
| 350 |
+
hex_count = len(re.findall(r"%[0-9a-fA-F]{2}", url))
|
| 351 |
+
at_count = url.count("@")
|
| 352 |
+
return hex_count + at_count
|
| 353 |
+
return np.nan
|
| 354 |
+
|
| 355 |
+
data[obf_char_col] = data.apply(
|
| 356 |
+
lambda row: count_obfuscated_characters(row[url_col])
|
| 357 |
+
if pd.isnull(row[obf_char_col])
|
| 358 |
+
else row[obf_char_col],
|
| 359 |
+
axis=1,
|
| 360 |
+
)
|
| 361 |
+
return data
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def fill_obfuscation_ratio(
|
| 365 |
+
data: pd.DataFrame,
|
| 366 |
+
obf_char_col: str = "NoOfObfuscatedChar",
|
| 367 |
+
url_length_col: str = "URLLength",
|
| 368 |
+
obf_ratio_col: str = "ObfuscationRatio",
|
| 369 |
+
) -> pd.DataFrame:
|
| 370 |
+
def calculate_obfuscation_ratio(no_of_obfchar: Any, url_length: Any) -> Any:
|
| 371 |
+
if pd.notnull(no_of_obfchar) and pd.notnull(url_length) and url_length > 0:
|
| 372 |
+
return no_of_obfchar / url_length
|
| 373 |
+
return np.nan
|
| 374 |
+
|
| 375 |
+
data[obf_ratio_col] = data.apply(
|
| 376 |
+
lambda row: calculate_obfuscation_ratio(row[obf_char_col], row[url_length_col])
|
| 377 |
+
if pd.isnull(row[obf_ratio_col])
|
| 378 |
+
else row[obf_ratio_col],
|
| 379 |
+
axis=1,
|
| 380 |
+
)
|
| 381 |
+
return data
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def fill_no_of_letters_in_url(
|
| 385 |
+
data: pd.DataFrame, url_col: str = "URL", letters_col: str = "NoOfLettersInURL"
|
| 386 |
+
) -> pd.DataFrame:
|
| 387 |
+
def calculate_no_of_letters(url: Any) -> Any:
|
| 388 |
+
if pd.notnull(url):
|
| 389 |
+
return sum(c.isalpha() for c in url)
|
| 390 |
+
return np.nan
|
| 391 |
+
|
| 392 |
+
data[letters_col] = data.apply(
|
| 393 |
+
lambda row: calculate_no_of_letters(row[url_col])
|
| 394 |
+
if pd.isnull(row[letters_col])
|
| 395 |
+
else row[letters_col],
|
| 396 |
+
axis=1,
|
| 397 |
+
)
|
| 398 |
+
return data
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def fill_letter_ratio_in_url(
|
| 402 |
+
data: pd.DataFrame,
|
| 403 |
+
letters_col: str = "NoOfLettersInURL",
|
| 404 |
+
url_length_col: str = "URLLength",
|
| 405 |
+
ratio_col: str = "LetterRatioInURL",
|
| 406 |
+
) -> pd.DataFrame:
|
| 407 |
+
def calculate_letter_ratio(no_of_letters: Any, url_length: Any) -> Any:
|
| 408 |
+
if pd.notnull(no_of_letters) and pd.notnull(url_length) and url_length > 0:
|
| 409 |
+
return no_of_letters / url_length
|
| 410 |
+
return np.nan
|
| 411 |
+
|
| 412 |
+
data[ratio_col] = data.apply(
|
| 413 |
+
lambda row: calculate_letter_ratio(row[letters_col], row[url_length_col])
|
| 414 |
+
if pd.isnull(row[ratio_col])
|
| 415 |
+
else row[ratio_col],
|
| 416 |
+
axis=1,
|
| 417 |
+
)
|
| 418 |
+
return data
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def fill_no_of_digits_in_url(
|
| 422 |
+
data: pd.DataFrame, url_col: str = "URL", digits_col: str = "NoOfDegitsInURL"
|
| 423 |
+
) -> pd.DataFrame:
|
| 424 |
+
def calculate_no_of_digits(url: Any) -> Any:
|
| 425 |
+
if pd.notnull(url):
|
| 426 |
+
return sum(c.isdigit() for c in url)
|
| 427 |
+
return np.nan
|
| 428 |
+
|
| 429 |
+
data[digits_col] = data.apply(
|
| 430 |
+
lambda row: calculate_no_of_digits(row[url_col])
|
| 431 |
+
if pd.isnull(row[digits_col])
|
| 432 |
+
else row[digits_col],
|
| 433 |
+
axis=1,
|
| 434 |
+
)
|
| 435 |
+
return data
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def fill_digits_ratio_in_url(
|
| 439 |
+
data: pd.DataFrame, url_col: str = "URL", ratio_col: str = "DegitRatioInURL"
|
| 440 |
+
) -> pd.DataFrame:
|
| 441 |
+
def calculate_digits_ratio(url: Any) -> Any:
|
| 442 |
+
if pd.notnull(url) and len(url) > 0:
|
| 443 |
+
return sum(c.isdigit() for c in url) / len(url)
|
| 444 |
+
return np.nan
|
| 445 |
+
|
| 446 |
+
data[ratio_col] = data.apply(
|
| 447 |
+
lambda row: calculate_digits_ratio(row[url_col])
|
| 448 |
+
if pd.isnull(row[ratio_col])
|
| 449 |
+
else row[ratio_col],
|
| 450 |
+
axis=1,
|
| 451 |
+
)
|
| 452 |
+
return data
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def fill_no_of_equals_in_url(
|
| 456 |
+
data: pd.DataFrame, url_col: str = "URL", equals_col: str = "NoOfEqualsInURL"
|
| 457 |
+
) -> pd.DataFrame:
|
| 458 |
+
def calculate_no_of_equals(url: Any) -> Any:
|
| 459 |
+
if pd.notnull(url):
|
| 460 |
+
return sum(c == "=" for c in url)
|
| 461 |
+
return np.nan
|
| 462 |
+
|
| 463 |
+
data[equals_col] = data.apply(
|
| 464 |
+
lambda row: calculate_no_of_equals(row[url_col])
|
| 465 |
+
if pd.isnull(row[equals_col])
|
| 466 |
+
else row[equals_col],
|
| 467 |
+
axis=1,
|
| 468 |
+
)
|
| 469 |
+
return data
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def fill_no_of_qmark_in_url(
|
| 473 |
+
data: pd.DataFrame, url_col: str = "URL", qmark_col: str = "NoOfQMarkInURL"
|
| 474 |
+
) -> pd.DataFrame:
|
| 475 |
+
def calculate_no_of_qmark(url: Any) -> Any:
|
| 476 |
+
if pd.notnull(url):
|
| 477 |
+
return sum(c == "?" for c in url)
|
| 478 |
+
return np.nan
|
| 479 |
+
|
| 480 |
+
data[qmark_col] = data.apply(
|
| 481 |
+
lambda row: calculate_no_of_qmark(row[url_col])
|
| 482 |
+
if pd.isnull(row[qmark_col])
|
| 483 |
+
else row[qmark_col],
|
| 484 |
+
axis=1,
|
| 485 |
+
)
|
| 486 |
+
return data
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def fill_no_of_ampersand_in_url(
|
| 490 |
+
data: pd.DataFrame, url_col: str = "URL", ampersand_col: str = "NoOfAmpersandInURL"
|
| 491 |
+
) -> pd.DataFrame:
|
| 492 |
+
def calculate_no_of_ampersand(url: Any) -> Any:
|
| 493 |
+
if pd.notnull(url):
|
| 494 |
+
return sum(c == "&" for c in url)
|
| 495 |
+
return np.nan
|
| 496 |
+
|
| 497 |
+
data[ampersand_col] = data.apply(
|
| 498 |
+
lambda row: calculate_no_of_ampersand(row[url_col])
|
| 499 |
+
if pd.isnull(row[ampersand_col])
|
| 500 |
+
else row[ampersand_col],
|
| 501 |
+
axis=1,
|
| 502 |
+
)
|
| 503 |
+
return data
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def fill_no_of_special_chars_in_url(
|
| 507 |
+
data: pd.DataFrame,
|
| 508 |
+
url_col: str = "URL",
|
| 509 |
+
special_chars_col: str = "NoOfOtherSpecialCharsInURL",
|
| 510 |
+
) -> pd.DataFrame:
|
| 511 |
+
def calculate_no_of_specials(url: Any) -> Any:
|
| 512 |
+
if pd.notnull(url):
|
| 513 |
+
return sum(c.lower() not in "0123456789abcdefghijklmnopqrstuvwxyz" for c in url)
|
| 514 |
+
return np.nan
|
| 515 |
+
|
| 516 |
+
data[special_chars_col] = data.apply(
|
| 517 |
+
lambda row: calculate_no_of_specials(row[url_col])
|
| 518 |
+
if pd.isnull(row[special_chars_col])
|
| 519 |
+
else row[special_chars_col],
|
| 520 |
+
axis=1,
|
| 521 |
+
)
|
| 522 |
+
return data
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def fill_specials_ratio_in_url(
|
| 526 |
+
data: pd.DataFrame, url_col: str = "URL", ratio_col: str = "SpacialCharRatioInURL"
|
| 527 |
+
) -> pd.DataFrame:
|
| 528 |
+
def calculate_specials_ratio(url: Any) -> Any:
|
| 529 |
+
if pd.notnull(url) and len(url) > 0:
|
| 530 |
+
return (
|
| 531 |
+
sum(c.lower() not in "0123456789abcdefghijklmnopqrstuvwxyz" for c in url)
|
| 532 |
+
/ len(url)
|
| 533 |
+
)
|
| 534 |
+
return np.nan
|
| 535 |
+
|
| 536 |
+
data[ratio_col] = data.apply(
|
| 537 |
+
lambda row: calculate_specials_ratio(row[url_col])
|
| 538 |
+
if pd.isnull(row[ratio_col])
|
| 539 |
+
else row[ratio_col],
|
| 540 |
+
axis=1,
|
| 541 |
+
)
|
| 542 |
+
return data
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def fill_is_https(
|
| 546 |
+
data: pd.DataFrame, url_col: str = "URL", https_col: str = "IsHTTPS"
|
| 547 |
+
) -> pd.DataFrame:
|
| 548 |
+
def calculate_ishttps(url: Any) -> Any:
|
| 549 |
+
if pd.notnull(url):
|
| 550 |
+
return 1 if "https://" in url else 0
|
| 551 |
+
return np.nan
|
| 552 |
+
|
| 553 |
+
data[https_col] = data.apply(
|
| 554 |
+
lambda row: calculate_ishttps(row[url_col])
|
| 555 |
+
if pd.isnull(row[https_col])
|
| 556 |
+
else row[https_col],
|
| 557 |
+
axis=1,
|
| 558 |
+
)
|
| 559 |
+
return data
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def fill_has_title(
|
| 563 |
+
data: pd.DataFrame, title_col: str = "Title", has_title_col: str = "HasTitle"
|
| 564 |
+
) -> pd.DataFrame:
|
| 565 |
+
data[has_title_col] = data.apply(
|
| 566 |
+
lambda row: row[has_title_col]
|
| 567 |
+
if not pd.isnull(row[has_title_col])
|
| 568 |
+
else (1 if not pd.isnull(row[title_col]) else row[has_title_col]),
|
| 569 |
+
axis=1,
|
| 570 |
+
)
|
| 571 |
+
return data
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def fill_domain_title_match_score(
|
| 575 |
+
data: pd.DataFrame,
|
| 576 |
+
title_col: str = "Title",
|
| 577 |
+
domain_col: str = "Domain",
|
| 578 |
+
score_col: str = "DomainTitleMatchScore",
|
| 579 |
+
) -> pd.DataFrame:
|
| 580 |
+
data[score_col] = data.apply(
|
| 581 |
+
lambda row: calculate_domain_title_match_score(row[title_col], row[domain_col])
|
| 582 |
+
if pd.isnull(row[score_col])
|
| 583 |
+
and pd.notnull(row[title_col])
|
| 584 |
+
and pd.notnull(row[domain_col])
|
| 585 |
+
else row[score_col],
|
| 586 |
+
axis=1,
|
| 587 |
+
)
|
| 588 |
+
return data
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def calculate_domain_title_match_score(title: Any, domain: Any) -> float:
|
| 592 |
+
tSet = title.split(" ") if pd.notnull(title) else []
|
| 593 |
+
txtDomain = domain.split(".")[:-1] if pd.notnull(domain) else []
|
| 594 |
+
txtDomain = [i for i in txtDomain if i != "www"]
|
| 595 |
+
txtDomain = ".".join(txtDomain)
|
| 596 |
+
|
| 597 |
+
score = 0.0
|
| 598 |
+
baseScore = 100 / len(txtDomain) if len(txtDomain) > 0 else 0
|
| 599 |
+
|
| 600 |
+
for element in tSet:
|
| 601 |
+
if element in txtDomain:
|
| 602 |
+
n = len(element)
|
| 603 |
+
score += baseScore * n
|
| 604 |
+
txtDomain = txtDomain.replace(element, "")
|
| 605 |
+
if score > 99.9:
|
| 606 |
+
score = 100
|
| 607 |
+
return score
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
def fill_url_title_match_score(
|
| 611 |
+
data: pd.DataFrame,
|
| 612 |
+
title_col: str = "Title",
|
| 613 |
+
url_col: str = "URL",
|
| 614 |
+
score_col: str = "URLTitleMatchScore",
|
| 615 |
+
) -> pd.DataFrame:
|
| 616 |
+
data[score_col] = data.apply(
|
| 617 |
+
lambda row: calculate_url_title_match_score(row[title_col], row[url_col])
|
| 618 |
+
if pd.isnull(row[score_col])
|
| 619 |
+
else row[score_col],
|
| 620 |
+
axis=1,
|
| 621 |
+
)
|
| 622 |
+
return data
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def calculate_url_title_match_score(title: Any, url: Any) -> Any:
|
| 626 |
+
if pd.notnull(title) and pd.notnull(url):
|
| 627 |
+
tSet = title.split(" ")
|
| 628 |
+
txtURL = urlparse(url).netloc.split(".")[:-1] + [
|
| 629 |
+
i for i in urlparse(url).path.split("/") if i != ""
|
| 630 |
+
]
|
| 631 |
+
txtURL = [i for i in txtURL if i != "www"]
|
| 632 |
+
txtURL = ".".join(txtURL)
|
| 633 |
+
|
| 634 |
+
score = 0.0
|
| 635 |
+
baseScore = 100 / len(txtURL) if len(txtURL) > 0 else 0
|
| 636 |
+
|
| 637 |
+
for element in tSet:
|
| 638 |
+
if element in txtURL:
|
| 639 |
+
n = len(element)
|
| 640 |
+
score += baseScore * n
|
| 641 |
+
txtURL = txtURL.replace(element, "")
|
| 642 |
+
if score > 99.9:
|
| 643 |
+
score = 100
|
| 644 |
+
return score
|
| 645 |
+
return np.nan
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
# ---------------------------------------------------------------------------
|
| 649 |
+
# Row-level twins
|
| 650 |
+
# ---------------------------------------------------------------------------
|
| 651 |
+
#
|
| 652 |
+
# These are a convenience for single-URL inference, which does not need a DataFrame apply
|
| 653 |
+
# to compute one value. They are NOT a second implementation: the DataFrame functions above
|
| 654 |
+
# remain the authority, and a property test asserts each twin agrees with its DataFrame
|
| 655 |
+
# counterpart on a one-row frame across a 5,000-URL sample. If the two ever disagree, the
|
| 656 |
+
# twin is what is wrong.
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def url_length(url: str | None) -> float | None:
|
| 660 |
+
return len(str(url)) if url is not None and pd.notnull(url) else None
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def domain_of(url: str | None) -> str | None:
|
| 664 |
+
return urlparse(url).netloc if url is not None and pd.notnull(url) else None
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def domain_length(domain: str | None) -> float | None:
|
| 668 |
+
return len(str(domain)) if domain is not None and pd.notnull(domain) else None
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def is_domain_ip(domain: str | None) -> int | None:
|
| 672 |
+
if domain is None or not pd.notnull(domain):
|
| 673 |
+
return None
|
| 674 |
+
if re.match(r"^(\d{1,3}\.){3}\d{1,3}$", domain):
|
| 675 |
+
return 1 if all(0 <= int(p) <= 255 for p in domain.split(".")) else 0
|
| 676 |
+
return 0
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
def tld_of(domain: str | None) -> str | None:
|
| 680 |
+
if domain is not None and pd.notnull(domain):
|
| 681 |
+
parts = domain.split(".")
|
| 682 |
+
if len(parts) > 1:
|
| 683 |
+
return parts[-1]
|
| 684 |
+
return None
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def char_continuation_rate(url: str | None) -> float | None:
|
| 688 |
+
if url is None or not pd.notnull(url):
|
| 689 |
+
return None
|
| 690 |
+
total = sum(len(s) for s in re.findall(r"[a-zA-Z0-9_]+", url))
|
| 691 |
+
return total / len(url) if len(url) > 0 else None
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def url_char_prob(url: str | None, char_prob: dict[str, float]) -> float | None:
|
| 695 |
+
if url is None or not pd.notnull(url):
|
| 696 |
+
return None
|
| 697 |
+
total = sum(char_prob.get(c, 0) for c in url.lower() if c.isalnum())
|
| 698 |
+
return total / len(url) if len(url) > 0 else None
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def tld_length(tld: str | None) -> float | None:
|
| 702 |
+
return len(str(tld)) if tld is not None and pd.notnull(tld) else None
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def no_of_subdomains(domain: str | None) -> float | None:
|
| 706 |
+
if domain is None or not pd.notnull(domain):
|
| 707 |
+
return None
|
| 708 |
+
parts = domain.split(".")
|
| 709 |
+
return len(parts) - 2 if len(parts) > 2 else 0
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def has_obfuscation(url: str | None) -> int:
|
| 713 |
+
return detect_advanced_obfuscation(url)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def no_of_obfuscated_chars(url: str | None) -> float | None:
|
| 717 |
+
if url is None or not pd.notnull(url):
|
| 718 |
+
return None
|
| 719 |
+
return len(re.findall(r"%[0-9a-fA-F]{2}", url)) + url.count("@")
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def no_of_letters(url: str | None) -> float | None:
|
| 723 |
+
return sum(c.isalpha() for c in url) if url is not None and pd.notnull(url) else None
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def no_of_digits(url: str | None) -> float | None:
|
| 727 |
+
return sum(c.isdigit() for c in url) if url is not None and pd.notnull(url) else None
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def no_of_equals(url: str | None) -> float | None:
|
| 731 |
+
return sum(c == "=" for c in url) if url is not None and pd.notnull(url) else None
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def no_of_qmark(url: str | None) -> float | None:
|
| 735 |
+
return sum(c == "?" for c in url) if url is not None and pd.notnull(url) else None
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def no_of_ampersand(url: str | None) -> float | None:
|
| 739 |
+
return sum(c == "&" for c in url) if url is not None and pd.notnull(url) else None
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
def no_of_special_chars(url: str | None) -> float | None:
|
| 743 |
+
if url is None or not pd.notnull(url):
|
| 744 |
+
return None
|
| 745 |
+
return sum(c.lower() not in "0123456789abcdefghijklmnopqrstuvwxyz" for c in url)
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def is_https(url: str | None) -> int | None:
|
| 749 |
+
if url is None or not pd.notnull(url):
|
| 750 |
+
return None
|
| 751 |
+
return 1 if "https://" in url else 0
|
phiusiil/models/__init__.py
ADDED
|
File without changes
|
phiusiil/models/base.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The interface all four models present.
|
| 2 |
+
|
| 3 |
+
The application treats a from-scratch implementation and its scikit-learn counterpart
|
| 4 |
+
identically, so neither gets special-cased in the interface layer.
|
| 5 |
+
|
| 6 |
+
``score`` is always P(class 0) -- the probability of *phishing*. Class 0 is the positive
|
| 7 |
+
class throughout this codebase, and a model that returned P(class 1) here would invert
|
| 8 |
+
every threshold, every ranking and every confidence display without failing a single type
|
| 9 |
+
check.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from abc import ABC, abstractmethod
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from phiusiil.schema import PHISHING_LABEL
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Classifier(ABC):
|
| 22 |
+
"""Fit on a float32 matrix; predict labels and phishing scores."""
|
| 23 |
+
|
| 24 |
+
#: Shown in the interface. The from-scratch pair is labelled as an educational
|
| 25 |
+
#: reimplementation rather than presented as an independent fourth opinion.
|
| 26 |
+
name: str = "classifier"
|
| 27 |
+
family: str = "unknown"
|
| 28 |
+
is_scratch: bool = False
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
def fit(self, X: np.ndarray, y: np.ndarray) -> Classifier: ...
|
| 32 |
+
|
| 33 |
+
@abstractmethod
|
| 34 |
+
def predict(self, X: np.ndarray) -> np.ndarray: ...
|
| 35 |
+
|
| 36 |
+
@abstractmethod
|
| 37 |
+
def score_phishing(self, X: np.ndarray) -> np.ndarray:
|
| 38 |
+
"""P(class 0) per row, in [0, 1]."""
|
| 39 |
+
|
| 40 |
+
def predict_at_threshold(self, X: np.ndarray, threshold: float) -> np.ndarray:
|
| 41 |
+
"""Label as phishing when the phishing score reaches the threshold."""
|
| 42 |
+
scores = self.score_phishing(X)
|
| 43 |
+
return np.where(scores >= threshold, PHISHING_LABEL, 1 - PHISHING_LABEL).astype(np.int64)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def as_float32(X: np.ndarray) -> np.ndarray:
|
| 47 |
+
"""Pin the dtype at every model boundary.
|
| 48 |
+
|
| 49 |
+
The transform emits float32 and the distance kernel expects it. Letting a float64
|
| 50 |
+
matrix through would silently change the arithmetic between training and serving,
|
| 51 |
+
which is the same class of skew the preprocessing split exists to prevent.
|
| 52 |
+
"""
|
| 53 |
+
array = np.asarray(X, dtype=np.float32)
|
| 54 |
+
if array.ndim == 1:
|
| 55 |
+
array = array.reshape(1, -1)
|
| 56 |
+
return array
|
phiusiil/models/knn_scratch.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""From-scratch k-nearest neighbours, vectorised.
|
| 2 |
+
|
| 3 |
+
The original iterated ``X_test.iterrows()`` in pure Python and took 5m43s to classify the
|
| 4 |
+
validation split. Same algorithm, same answers, expressed as matrix arithmetic.
|
| 5 |
+
|
| 6 |
+
THE TIE-BREAKING HAZARD
|
| 7 |
+
=======================
|
| 8 |
+
|
| 9 |
+
This is the one subtlety, and it is easy to vectorise straight past.
|
| 10 |
+
|
| 11 |
+
The original selected neighbours with ``np.argsort(distances)[:k]`` and voted with
|
| 12 |
+
``Counter(...).most_common(1)``. When two classes tie on count, ``most_common`` returns
|
| 13 |
+
whichever was inserted into the counter first -- which, given argsort's output, is the
|
| 14 |
+
class of the nearest neighbour among the k.
|
| 15 |
+
|
| 16 |
+
``np.argpartition`` is the fast way to take the k smallest, and it does *not* order the k
|
| 17 |
+
it returns. Feeding its output straight into the vote silently changes which class wins
|
| 18 |
+
every tie, and with an even k ties are common rather than exotic. So the k selected
|
| 19 |
+
indices are re-sorted by distance before voting. Without that line the vectorised model
|
| 20 |
+
disagrees with the original on a subset of rows and no test that only checks accuracy
|
| 21 |
+
would notice.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from collections import Counter
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
from phiusiil.models.base import Classifier, as_float32
|
| 31 |
+
from phiusiil.schema import PHISHING_LABEL
|
| 32 |
+
|
| 33 |
+
#: Query rows per chunk. Bounds the distance matrix at chunk x n_reference floats, which
|
| 34 |
+
#: at 512 x 10,000 float32 is about 20 MB -- large enough to keep BLAS busy, small enough
|
| 35 |
+
#: that it never dominates the container's memory budget.
|
| 36 |
+
CHUNK_SIZE = 512
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ScratchKNN(Classifier):
|
| 40 |
+
name = "KNN (from scratch)"
|
| 41 |
+
family = "knn"
|
| 42 |
+
is_scratch = True
|
| 43 |
+
|
| 44 |
+
def __init__(self, k: int = 20, metric: str = "euclidean", p: int = 1) -> None:
|
| 45 |
+
if not isinstance(k, int) or k < 1:
|
| 46 |
+
raise ValueError("Invalid neighbor count. k must be an integer greater than 0.")
|
| 47 |
+
if metric not in {"manhattan", "euclidean", "minkowski"}:
|
| 48 |
+
raise ValueError(
|
| 49 |
+
"Invalid distance metric. Valid metric: 'euclidean', 'manhattan', or 'minkowski'."
|
| 50 |
+
)
|
| 51 |
+
if not isinstance(p, int) or p < 1:
|
| 52 |
+
raise ValueError("Invalid minkowski distance variable. p must be an integer > 0.")
|
| 53 |
+
|
| 54 |
+
self.k = k
|
| 55 |
+
self.metric = metric
|
| 56 |
+
self.p = p
|
| 57 |
+
self.X_train: np.ndarray | None = None
|
| 58 |
+
self.y_train: np.ndarray | None = None
|
| 59 |
+
|
| 60 |
+
def fit(self, X: np.ndarray, y: np.ndarray) -> ScratchKNN:
|
| 61 |
+
self.X_train = as_float32(X)
|
| 62 |
+
self.y_train = np.asarray(y, dtype=np.int64)
|
| 63 |
+
if len(self.X_train) != len(self.y_train):
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"reference set and labels disagree: {len(self.X_train)} rows, "
|
| 66 |
+
f"{len(self.y_train)} labels"
|
| 67 |
+
)
|
| 68 |
+
return self
|
| 69 |
+
|
| 70 |
+
def _distances(self, Q: np.ndarray) -> np.ndarray:
|
| 71 |
+
assert self.X_train is not None
|
| 72 |
+
R = self.X_train
|
| 73 |
+
|
| 74 |
+
if self.metric == "euclidean":
|
| 75 |
+
# d^2 = |q|^2 - 2 q.r + |r|^2, which turns the whole distance computation into
|
| 76 |
+
# one matrix product. Small negatives appear from cancellation when a query
|
| 77 |
+
# nearly coincides with a reference point, so the result is clipped at zero
|
| 78 |
+
# before the square root.
|
| 79 |
+
q_sq = np.einsum("ij,ij->i", Q, Q)[:, None]
|
| 80 |
+
r_sq = np.einsum("ij,ij->i", R, R)[None, :]
|
| 81 |
+
d2 = q_sq - 2.0 * (Q @ R.T) + r_sq
|
| 82 |
+
np.maximum(d2, 0.0, out=d2)
|
| 83 |
+
return np.sqrt(d2)
|
| 84 |
+
|
| 85 |
+
if self.metric == "manhattan":
|
| 86 |
+
return np.abs(Q[:, None, :] - R[None, :, :]).sum(axis=2)
|
| 87 |
+
|
| 88 |
+
diff = np.abs(Q[:, None, :] - R[None, :, :]) ** self.p
|
| 89 |
+
return diff.sum(axis=2) ** (1.0 / self.p)
|
| 90 |
+
|
| 91 |
+
def _neighbours(self, Q: np.ndarray) -> np.ndarray:
|
| 92 |
+
"""Indices of the k nearest reference rows, ordered nearest-first."""
|
| 93 |
+
assert self.X_train is not None
|
| 94 |
+
n_ref = len(self.X_train)
|
| 95 |
+
k = min(self.k, n_ref)
|
| 96 |
+
|
| 97 |
+
distances = self._distances(Q)
|
| 98 |
+
|
| 99 |
+
if k < n_ref:
|
| 100 |
+
candidates = np.argpartition(distances, kth=k - 1, axis=1)[:, :k]
|
| 101 |
+
else:
|
| 102 |
+
candidates = np.tile(np.arange(n_ref), (len(Q), 1))
|
| 103 |
+
|
| 104 |
+
# Re-sort the selected k by distance. argpartition leaves them unordered, and the
|
| 105 |
+
# vote's tie-break depends on nearest-first order.
|
| 106 |
+
rows = np.arange(len(Q))[:, None]
|
| 107 |
+
order = np.argsort(distances[rows, candidates], axis=1, kind="stable")
|
| 108 |
+
return candidates[rows, order]
|
| 109 |
+
|
| 110 |
+
def _vote(self, neighbour_idx: np.ndarray) -> np.ndarray:
|
| 111 |
+
assert self.y_train is not None
|
| 112 |
+
labels = self.y_train[neighbour_idx]
|
| 113 |
+
out = np.empty(len(labels), dtype=np.int64)
|
| 114 |
+
for i, row in enumerate(labels):
|
| 115 |
+
# Counter preserves insertion order, and the row is nearest-first, so a tie
|
| 116 |
+
# resolves to the class of the nearest neighbour -- matching the original.
|
| 117 |
+
out[i] = Counter(row.tolist()).most_common(1)[0][0]
|
| 118 |
+
return out
|
| 119 |
+
|
| 120 |
+
def predict(self, X: np.ndarray) -> np.ndarray:
|
| 121 |
+
if self.X_train is None or self.y_train is None:
|
| 122 |
+
raise RuntimeError("ScratchKNN.predict called before fit()")
|
| 123 |
+
|
| 124 |
+
Q = as_float32(X)
|
| 125 |
+
predictions = np.empty(len(Q), dtype=np.int64)
|
| 126 |
+
for start in range(0, len(Q), CHUNK_SIZE):
|
| 127 |
+
chunk = Q[start : start + CHUNK_SIZE]
|
| 128 |
+
predictions[start : start + len(chunk)] = self._vote(self._neighbours(chunk))
|
| 129 |
+
return predictions
|
| 130 |
+
|
| 131 |
+
def score_phishing(self, X: np.ndarray) -> np.ndarray:
|
| 132 |
+
"""Share of the k neighbours that are phishing.
|
| 133 |
+
|
| 134 |
+
A vote fraction, not a calibrated probability. It is monotone in the evidence and
|
| 135 |
+
that is all the threshold slider needs; presenting it as a calibrated posterior
|
| 136 |
+
would overstate what k=20 nearest neighbours can tell you.
|
| 137 |
+
"""
|
| 138 |
+
if self.X_train is None or self.y_train is None:
|
| 139 |
+
raise RuntimeError("ScratchKNN.score_phishing called before fit()")
|
| 140 |
+
|
| 141 |
+
Q = as_float32(X)
|
| 142 |
+
scores = np.empty(len(Q), dtype=np.float64)
|
| 143 |
+
for start in range(0, len(Q), CHUNK_SIZE):
|
| 144 |
+
chunk = Q[start : start + CHUNK_SIZE]
|
| 145 |
+
neighbours = self._neighbours(chunk)
|
| 146 |
+
labels = self.y_train[neighbours]
|
| 147 |
+
scores[start : start + len(chunk)] = (labels == PHISHING_LABEL).mean(axis=1)
|
| 148 |
+
return scores
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class NaiveScratchKNN(ScratchKNN):
|
| 152 |
+
"""The original row-at-a-time loop, kept as the reference for the vectorised version.
|
| 153 |
+
|
| 154 |
+
Used only by the equivalence test. It is far too slow to serve with -- which is the
|
| 155 |
+
entire reason the vectorised implementation exists -- but a fast implementation with
|
| 156 |
+
no slow one to check it against is just an assertion.
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
name = "KNN (from scratch, naive loop)"
|
| 160 |
+
|
| 161 |
+
def predict(self, X: np.ndarray) -> np.ndarray:
|
| 162 |
+
if self.X_train is None or self.y_train is None:
|
| 163 |
+
raise RuntimeError("NaiveScratchKNN.predict called before fit()")
|
| 164 |
+
|
| 165 |
+
Q = as_float32(X)
|
| 166 |
+
predictions = []
|
| 167 |
+
for row in Q:
|
| 168 |
+
match self.metric:
|
| 169 |
+
case "euclidean":
|
| 170 |
+
distances = np.sqrt(np.sum(np.square(row - self.X_train), axis=1))
|
| 171 |
+
case "manhattan":
|
| 172 |
+
distances = np.sum(np.abs(row - self.X_train), axis=1)
|
| 173 |
+
case "minkowski":
|
| 174 |
+
distances = np.sum(np.abs(row - self.X_train) ** self.p, axis=1) ** (
|
| 175 |
+
1 / self.p
|
| 176 |
+
)
|
| 177 |
+
case _:
|
| 178 |
+
raise ValueError("Invalid distance metric.")
|
| 179 |
+
|
| 180 |
+
neighbours = np.argsort(distances, kind="stable")[: self.k]
|
| 181 |
+
classes = Counter(self.y_train[neighbours].tolist())
|
| 182 |
+
predictions.append(classes.most_common(1)[0][0])
|
| 183 |
+
return np.asarray(predictions, dtype=np.int64)
|
phiusiil/preprocess/__init__.py
ADDED
|
File without changes
|
phiusiil/preprocess/scaler.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standardisation over a named subset of columns.
|
| 2 |
+
|
| 3 |
+
Scales exactly the 30 numeric columns and passes the 19 binary indicators through
|
| 4 |
+
bit-identically.
|
| 5 |
+
|
| 6 |
+
WHY THE SUBSET IS DELIBERATE
|
| 7 |
+
============================
|
| 8 |
+
|
| 9 |
+
This started as an accident. In the original, the clipping helper and the standardisation
|
| 10 |
+
path both closed over a notebook-level ``numerical_columns`` global, so the 19 binary
|
| 11 |
+
columns were never in scope for either. Nobody decided this; it fell out of variable
|
| 12 |
+
scoping.
|
| 13 |
+
|
| 14 |
+
It is now a frozen decision, for two reasons:
|
| 15 |
+
|
| 16 |
+
1. The 19 columns are already 0/1 indicators. Z-scoring a Bernoulli indicator maps it to
|
| 17 |
+
two points at -p/sqrt(p(1-p)) and (1-p)/sqrt(p(1-p)); it changes the scale of an
|
| 18 |
+
already-comparable quantity, and for a rare indicator it inflates the minority level's
|
| 19 |
+
magnitude substantially.
|
| 20 |
+
2. Changing it would change every recorded metric. Reporting as-recorded beside
|
| 21 |
+
as-corrected is only meaningful if the two differ by one identified cause -- the
|
| 22 |
+
fit/transform fix -- rather than by a bundle of simultaneous changes.
|
| 23 |
+
|
| 24 |
+
THE HONEST CAVEAT
|
| 25 |
+
=================
|
| 26 |
+
|
| 27 |
+
A Euclidean distance over 30 z-scored dimensions plus 19 raw 0/1 dimensions is not a
|
| 28 |
+
principled metric. The unit-variance dimensions and the at-most-0.25-variance dimensions
|
| 29 |
+
contribute on different scales, and the effective weighting is an accident of where the
|
| 30 |
+
fix stopped rather than a modelling choice. KNN is the model that cares; Gaussian NB
|
| 31 |
+
treats each dimension independently and its decision rule is invariant to per-feature
|
| 32 |
+
affine rescaling. An ablation that scales all 49 is reported as a comparison, not adopted
|
| 33 |
+
as the default -- changing it is a product decision with a metric-invalidation cost, not a
|
| 34 |
+
silent bugfix.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
import pandas as pd
|
| 41 |
+
|
| 42 |
+
from phiusiil.preprocess.stats import ScalerParams
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class SubsetStandardScaler:
|
| 46 |
+
"""Fit and apply a per-column affine map over a fixed column subset.
|
| 47 |
+
|
| 48 |
+
Note what this class does not have: a ``fit_transform``. Its absence is the point.
|
| 49 |
+
Serving-time code receives :class:`ScalerParams` -- plain numbers -- and can only
|
| 50 |
+
apply them.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, columns: tuple[str, ...]) -> None:
|
| 54 |
+
self.columns = tuple(columns)
|
| 55 |
+
|
| 56 |
+
def fit(self, X: pd.DataFrame) -> ScalerParams:
|
| 57 |
+
missing = [c for c in self.columns if c not in X.columns]
|
| 58 |
+
if missing:
|
| 59 |
+
raise KeyError(f"cannot fit scaler, columns absent from frame: {sorted(missing)}")
|
| 60 |
+
|
| 61 |
+
block = X[list(self.columns)].astype(np.float64)
|
| 62 |
+
mean_ = block.mean(axis=0).to_numpy()
|
| 63 |
+
|
| 64 |
+
# Population standard deviation (ddof=0), matching StandardScaler. Using the
|
| 65 |
+
# sample standard deviation here would shift every scaled value by a factor of
|
| 66 |
+
# sqrt(n/(n-1)) relative to the reference implementation.
|
| 67 |
+
scale_ = block.std(axis=0, ddof=0).to_numpy()
|
| 68 |
+
|
| 69 |
+
# A constant column has zero variance and would divide by zero. Mapping its scale
|
| 70 |
+
# to 1.0 turns it into a pure mean-shift, which is what StandardScaler does too.
|
| 71 |
+
scale_ = np.where(scale_ == 0.0, 1.0, scale_)
|
| 72 |
+
|
| 73 |
+
return ScalerParams(
|
| 74 |
+
columns=self.columns,
|
| 75 |
+
mean_=tuple(float(v) for v in mean_),
|
| 76 |
+
scale_=tuple(float(v) for v in scale_),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def apply(X: pd.DataFrame, params: ScalerParams) -> pd.DataFrame:
|
| 81 |
+
"""Apply a fitted affine map in place on a copy.
|
| 82 |
+
|
| 83 |
+
Elementwise and row-local by construction: the value written for row i depends
|
| 84 |
+
only on row i and on the persisted constants. That is what makes transforming one
|
| 85 |
+
row and transforming a batch produce bitwise-identical output.
|
| 86 |
+
"""
|
| 87 |
+
missing = [c for c in params.columns if c not in X.columns]
|
| 88 |
+
if missing:
|
| 89 |
+
raise KeyError(f"cannot scale, columns absent from frame: {sorted(missing)}")
|
| 90 |
+
|
| 91 |
+
cols = list(params.columns)
|
| 92 |
+
mean_ = np.asarray(params.mean_, dtype=np.float64)
|
| 93 |
+
scale_ = np.asarray(params.scale_, dtype=np.float64)
|
| 94 |
+
|
| 95 |
+
X = X.copy()
|
| 96 |
+
X[cols] = (X[cols].astype(np.float64).to_numpy() - mean_) / scale_
|
| 97 |
+
return X
|
phiusiil/preprocess/stats.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Everything learned at fit time, in one immutable record.
|
| 2 |
+
|
| 3 |
+
The governing rule of this package: **transform() may not compute any statistic.**
|
| 4 |
+
|
| 5 |
+
Operationally, inside ``transform`` there is no call to ``.mean()``, ``.median()``,
|
| 6 |
+
``.mode()``, ``.std()``, ``.skew()``, ``.quantile()``, ``.value_counts()``,
|
| 7 |
+
``.groupby(...).agg(...)``, ``fit()`` or ``fit_transform()`` on the input frame. Anything
|
| 8 |
+
depending on more than the current row is a fitted statistic: computed once in ``fit()``,
|
| 9 |
+
stored here, serialized, and read back at serving time.
|
| 10 |
+
|
| 11 |
+
This is not a style preference. The original recomputed every statistic from whatever
|
| 12 |
+
batch it was handed, and standardized the training and validation splits by their own
|
| 13 |
+
separate means -- so the scaling that produced its headline numbers does not exist at
|
| 14 |
+
serving time and cannot be reconstructed, because there is no batch to take a mean over
|
| 15 |
+
when a user pastes one URL. Making the statistics data rather than control flow is the
|
| 16 |
+
precondition for the service existing at all.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Literal
|
| 23 |
+
|
| 24 |
+
FillMethod = Literal["mean", "median"]
|
| 25 |
+
NbDropReason = Literal["empty_contingency_cell", "zero_std"]
|
| 26 |
+
FallbackSource = Literal["numeric_mean", "numeric_median", "categorical_mode"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class NumericFill:
|
| 31 |
+
"""Imputation value for one numeric column.
|
| 32 |
+
|
| 33 |
+
``skew`` is recorded even though ``transform`` never reads it, so a reader can audit
|
| 34 |
+
which branch was taken without re-running training.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
column: str
|
| 38 |
+
method: FillMethod # median iff abs(skew) > 3
|
| 39 |
+
value: float
|
| 40 |
+
skew: float
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class CategoricalFillStep:
|
| 45 |
+
"""One step of the ordered categorical cascade.
|
| 46 |
+
|
| 47 |
+
``groupby_cols`` is a snapshot of the group key *at this step*, persisted explicitly
|
| 48 |
+
rather than reconstructed as "the first k-1 filled columns plus HasObfuscation". If
|
| 49 |
+
the cascade is ever edited, the artifact still describes what actually ran.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
column: str
|
| 53 |
+
groupby_cols: tuple[str, ...]
|
| 54 |
+
modes: dict[tuple[float, ...], float]
|
| 55 |
+
global_mode: float
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass(frozen=True)
|
| 59 |
+
class ClipBound:
|
| 60 |
+
column: str
|
| 61 |
+
lower: float # 1st percentile of the training split
|
| 62 |
+
upper: float # 99th percentile of the training split
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass(frozen=True)
|
| 66 |
+
class ScalerParams:
|
| 67 |
+
"""An affine map, stored as plain numbers.
|
| 68 |
+
|
| 69 |
+
Deliberately not a pickled StandardScaler. A pickled estimator carries a
|
| 70 |
+
``fit_transform`` method, and the entire class of defect this package exists to
|
| 71 |
+
eliminate is someone calling it at serving time. Numbers cannot be re-fitted.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
columns: tuple[str, ...] # exactly the 30 numeric columns, ordered
|
| 75 |
+
mean_: tuple[float, ...]
|
| 76 |
+
scale_: tuple[float, ...] # zeros replaced by 1.0 so transform cannot divide by zero
|
| 77 |
+
|
| 78 |
+
def __post_init__(self) -> None:
|
| 79 |
+
if not (len(self.columns) == len(self.mean_) == len(self.scale_)):
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"scaler arity mismatch: {len(self.columns)} columns, "
|
| 82 |
+
f"{len(self.mean_)} means, {len(self.scale_)} scales"
|
| 83 |
+
)
|
| 84 |
+
if any(s == 0.0 for s in self.scale_):
|
| 85 |
+
raise ValueError("scale_ contains a zero; constant columns must be mapped to 1.0")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@dataclass(frozen=True)
|
| 89 |
+
class HtmlFallback:
|
| 90 |
+
"""The value a page-derived feature takes when the fetch failed or the feature was
|
| 91 |
+
demoted.
|
| 92 |
+
|
| 93 |
+
Derived, not separately computed: each entry is exactly the value this column's own
|
| 94 |
+
fill rule already produces. It is lifted into its own record purely so the interface
|
| 95 |
+
can *display* the fallback and where it came from, rather than silently applying it.
|
| 96 |
+
A mismatch between this table and the fills it was derived from is a bug, and a test
|
| 97 |
+
asserts they agree.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
column: str
|
| 101 |
+
value: float
|
| 102 |
+
source: FallbackSource
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass(frozen=True)
|
| 106 |
+
class FittedStats:
|
| 107 |
+
"""The complete serialized state of a fitted preprocessor."""
|
| 108 |
+
|
| 109 |
+
# --- URL character model -------------------------------------------------
|
| 110 |
+
char_prob: dict[str, float]
|
| 111 |
+
|
| 112 |
+
# --- TLD legitimacy ------------------------------------------------------
|
| 113 |
+
tld_prob_mean: dict[str, float]
|
| 114 |
+
tld_prob_global_fill: float
|
| 115 |
+
tld_skew: float
|
| 116 |
+
tld_fill_method: FillMethod # median iff abs(tld_skew) > 1 -- note: 1, not 3
|
| 117 |
+
|
| 118 |
+
# --- numeric imputation --------------------------------------------------
|
| 119 |
+
numeric_fill: tuple[NumericFill, ...]
|
| 120 |
+
|
| 121 |
+
# --- categorical cascade. ORDER IS SEMANTIC ------------------------------
|
| 122 |
+
categorical_cascade: tuple[CategoricalFillStep, ...]
|
| 123 |
+
|
| 124 |
+
# --- outlier clipping ----------------------------------------------------
|
| 125 |
+
clip_bounds: tuple[ClipBound, ...]
|
| 126 |
+
|
| 127 |
+
# --- scaling -------------------------------------------------------------
|
| 128 |
+
scaler: ScalerParams
|
| 129 |
+
|
| 130 |
+
# --- Naive-Bayes column drop, fitted here for locality -------------------
|
| 131 |
+
nb_drop: tuple[str, ...]
|
| 132 |
+
nb_drop_reasons: dict[str, NbDropReason]
|
| 133 |
+
|
| 134 |
+
# --- page-feature fallbacks ---------------------------------------------
|
| 135 |
+
html_fallbacks: tuple[HtmlFallback, ...]
|
| 136 |
+
|
| 137 |
+
# --- frozen column lists, so nothing closes over a module global ---------
|
| 138 |
+
feature_order: tuple[str, ...]
|
| 139 |
+
numerical_columns: tuple[str, ...]
|
| 140 |
+
continuous_columns: tuple[str, ...]
|
| 141 |
+
discrete_columns: tuple[str, ...]
|
| 142 |
+
categorical_columns_filtered: tuple[str, ...]
|
| 143 |
+
|
| 144 |
+
# --- provenance ----------------------------------------------------------
|
| 145 |
+
n_train_rows: int = 0
|
| 146 |
+
demoted_features: tuple[str, ...] = field(default_factory=tuple)
|
| 147 |
+
|
| 148 |
+
def __post_init__(self) -> None:
|
| 149 |
+
if len(self.feature_order) != 49:
|
| 150 |
+
raise ValueError(f"feature_order must have 49 entries, got {len(self.feature_order)}")
|
| 151 |
+
if len(self.numerical_columns) != 30:
|
| 152 |
+
raise ValueError(
|
| 153 |
+
f"numerical_columns must have 30 entries, got {len(self.numerical_columns)}"
|
| 154 |
+
)
|
| 155 |
+
if len(self.categorical_columns_filtered) != 19:
|
| 156 |
+
raise ValueError(
|
| 157 |
+
"categorical_columns_filtered must have 19 entries, got "
|
| 158 |
+
f"{len(self.categorical_columns_filtered)}"
|
| 159 |
+
)
|
| 160 |
+
if len(self.categorical_cascade) != 18:
|
| 161 |
+
raise ValueError(
|
| 162 |
+
f"categorical_cascade must have 18 steps, got {len(self.categorical_cascade)}"
|
| 163 |
+
)
|
| 164 |
+
if tuple(self.scaler.columns) != tuple(self.numerical_columns):
|
| 165 |
+
raise ValueError(
|
| 166 |
+
"scaler.columns must be exactly numerical_columns, in the same order. "
|
| 167 |
+
"A mismatch here means the matrix was scaled on a different column set "
|
| 168 |
+
"than the one recorded, which silently changes every distance."
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def numeric_fill_map(self) -> dict[str, float]:
|
| 173 |
+
return {f.column: f.value for f in self.numeric_fill}
|
| 174 |
+
|
| 175 |
+
@property
|
| 176 |
+
def html_fallback_map(self) -> dict[str, float]:
|
| 177 |
+
return {f.column: f.value for f in self.html_fallbacks}
|
phiusiil/preprocess/transformer.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The fit/transform split.
|
| 2 |
+
|
| 3 |
+
``fit`` learns; ``transform`` only reads. There is no ``transform_single``, no
|
| 4 |
+
``predict_one`` shortcut, and no "skip clipping for a single row" fast path. Every such
|
| 5 |
+
special case is a place where the serving path can drift from the training path, and drift
|
| 6 |
+
of exactly that kind is the defect this module exists to eliminate.
|
| 7 |
+
|
| 8 |
+
The proof obligation is a test, not a comment: transforming n rows individually must equal
|
| 9 |
+
transforming them as one batch, bitwise on float32. Any tolerance-based comparison would
|
| 10 |
+
pass even if a statistic were being recomputed from a batch that happens to resemble the
|
| 11 |
+
training set. Exact equality can only hold if transform reads its numbers from FittedStats.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
from phiusiil import schema
|
| 23 |
+
from phiusiil.features import url_features as uf
|
| 24 |
+
from phiusiil.preprocess.scaler import SubsetStandardScaler
|
| 25 |
+
from phiusiil.preprocess.stats import (
|
| 26 |
+
CategoricalFillStep,
|
| 27 |
+
ClipBound,
|
| 28 |
+
FallbackSource,
|
| 29 |
+
FillMethod,
|
| 30 |
+
FittedStats,
|
| 31 |
+
HtmlFallback,
|
| 32 |
+
NbDropReason,
|
| 33 |
+
NumericFill,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class FeatureContractError(RuntimeError):
|
| 38 |
+
"""A feature column is absent at reindex time.
|
| 39 |
+
|
| 40 |
+
Distinct from a missing *value*, which is normal and is imputed. An absent column
|
| 41 |
+
means the extractor's contract broke, and failing loudly is correct.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class NotFittedError(RuntimeError):
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _apply_url_fill_chain(
|
| 50 |
+
X: pd.DataFrame,
|
| 51 |
+
*,
|
| 52 |
+
char_prob: dict[str, float],
|
| 53 |
+
tld_prob_mean: pd.Series,
|
| 54 |
+
tld_prob_global_fill: float,
|
| 55 |
+
) -> pd.DataFrame:
|
| 56 |
+
"""The 26 fill_* functions, in the one order their dependencies allow.
|
| 57 |
+
|
| 58 |
+
Domain must precede DomainLength, TLD and NoOfSubDomain; TLD must precede TLDLength
|
| 59 |
+
and TLDLegitimateProb; URLLength must precede every ratio that divides by it;
|
| 60 |
+
IsDomainIP must precede TLDLegitimateProb.
|
| 61 |
+
|
| 62 |
+
Every function here is row-local given the three injected statistics, which is the
|
| 63 |
+
property the invariance test depends on.
|
| 64 |
+
"""
|
| 65 |
+
X = uf.fill_url_length(X, url_col="URL", url_length_col="URLLength")
|
| 66 |
+
X = uf.fill_domain(X, url_col="URL", domain_col="Domain")
|
| 67 |
+
X = uf.fill_domain_length(X, domain_col="Domain", domain_length_col="DomainLength")
|
| 68 |
+
X = uf.fill_is_domain_ip(X, domain_col="Domain", is_domain_ip_col="IsDomainIP")
|
| 69 |
+
X = uf.fill_tld(X, domain_col="Domain", tld_col="TLD")
|
| 70 |
+
X = uf.fill_char_continuation_rate(X, url_col="URL", char_rate_col="CharContinuationRate")
|
| 71 |
+
X = uf.fill_tld_legitimate_prob(
|
| 72 |
+
X,
|
| 73 |
+
tld_prob_mean=tld_prob_mean,
|
| 74 |
+
global_fill_value=tld_prob_global_fill,
|
| 75 |
+
tld_col="TLD",
|
| 76 |
+
tld_prob_col="TLDLegitimateProb",
|
| 77 |
+
is_domain_ip_col="IsDomainIP",
|
| 78 |
+
)
|
| 79 |
+
X = uf.fill_url_char_prob(
|
| 80 |
+
X, url_col="URL", char_prob_col="URLCharProb", char_prob=char_prob
|
| 81 |
+
)
|
| 82 |
+
X = uf.fill_tld_length(X, tld_col="TLD", tld_length_col="TLDLength")
|
| 83 |
+
X = uf.fill_no_of_subdomains(X, domain_col="Domain", subdomain_col="NoOfSubDomain")
|
| 84 |
+
X = uf.fill_has_obfuscation(X, url_col="URL", obfuscation_col="HasObfuscation")
|
| 85 |
+
X = uf.fill_no_of_obfuscated_characters(X, url_col="URL", obf_char_col="NoOfObfuscatedChar")
|
| 86 |
+
X = uf.fill_obfuscation_ratio(
|
| 87 |
+
X,
|
| 88 |
+
obf_char_col="NoOfObfuscatedChar",
|
| 89 |
+
url_length_col="URLLength",
|
| 90 |
+
obf_ratio_col="ObfuscationRatio",
|
| 91 |
+
)
|
| 92 |
+
X = uf.fill_no_of_letters_in_url(X, url_col="URL", letters_col="NoOfLettersInURL")
|
| 93 |
+
X = uf.fill_letter_ratio_in_url(
|
| 94 |
+
X,
|
| 95 |
+
letters_col="NoOfLettersInURL",
|
| 96 |
+
url_length_col="URLLength",
|
| 97 |
+
ratio_col="LetterRatioInURL",
|
| 98 |
+
)
|
| 99 |
+
X = uf.fill_no_of_digits_in_url(X, url_col="URL", digits_col="NoOfDegitsInURL")
|
| 100 |
+
X = uf.fill_digits_ratio_in_url(X, url_col="URL", ratio_col="DegitRatioInURL")
|
| 101 |
+
X = uf.fill_no_of_equals_in_url(X, url_col="URL", equals_col="NoOfEqualsInURL")
|
| 102 |
+
X = uf.fill_no_of_qmark_in_url(X, url_col="URL", qmark_col="NoOfQMarkInURL")
|
| 103 |
+
X = uf.fill_no_of_ampersand_in_url(X, url_col="URL", ampersand_col="NoOfAmpersandInURL")
|
| 104 |
+
X = uf.fill_no_of_special_chars_in_url(
|
| 105 |
+
X, url_col="URL", special_chars_col="NoOfOtherSpecialCharsInURL"
|
| 106 |
+
)
|
| 107 |
+
X = uf.fill_specials_ratio_in_url(X, url_col="URL", ratio_col="SpacialCharRatioInURL")
|
| 108 |
+
X = uf.fill_is_https(X, url_col="URL", https_col="IsHTTPS")
|
| 109 |
+
X = uf.fill_has_title(X, title_col="Title", has_title_col="HasTitle")
|
| 110 |
+
X = uf.fill_domain_title_match_score(
|
| 111 |
+
X, title_col="Title", domain_col="Domain", score_col="DomainTitleMatchScore"
|
| 112 |
+
)
|
| 113 |
+
return uf.fill_url_title_match_score(
|
| 114 |
+
X, title_col="Title", url_col="URL", score_col="URLTitleMatchScore"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _group_mode(values: pd.Series) -> Any:
|
| 119 |
+
"""Most frequent value in a group, or None when the group is entirely missing."""
|
| 120 |
+
mode = values.mode()
|
| 121 |
+
return mode.iloc[0] if not mode.empty else None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _cascade_key(row: Any, groupby_cols: tuple[str, ...]) -> tuple[float, ...]:
|
| 125 |
+
"""Build the cascade lookup key for one row.
|
| 126 |
+
|
| 127 |
+
Values are coerced to float so that a key built from an int column and a key built
|
| 128 |
+
from the same column after a float round-trip compare equal. Without this, a one-row
|
| 129 |
+
frame (whose columns pandas may infer as int64) and a batch frame (float64 because
|
| 130 |
+
some other row had a NaN) would miss each other's mode tables, and invariance would
|
| 131 |
+
fail for reasons that have nothing to do with the statistics.
|
| 132 |
+
"""
|
| 133 |
+
return tuple(float(row[c]) for c in groupby_cols)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class Preprocessor:
|
| 137 |
+
"""Learns from the training split, then applies what it learned and nothing else."""
|
| 138 |
+
|
| 139 |
+
def __init__(self, stats: FittedStats | None = None) -> None:
|
| 140 |
+
self._stats = stats
|
| 141 |
+
#: Per-cascade-step count of rows that fell back to the global mode. Instrumented
|
| 142 |
+
#: because a step with a high fallback rate is telling you its conditioning is
|
| 143 |
+
#: doing nothing useful -- worth knowing, not an error.
|
| 144 |
+
self.fallback_counts: Counter[str] = Counter()
|
| 145 |
+
|
| 146 |
+
@property
|
| 147 |
+
def stats(self) -> FittedStats:
|
| 148 |
+
if self._stats is None:
|
| 149 |
+
raise NotFittedError("Preprocessor.transform called before fit(); no statistics loaded")
|
| 150 |
+
return self._stats
|
| 151 |
+
|
| 152 |
+
@property
|
| 153 |
+
def is_fitted(self) -> bool:
|
| 154 |
+
return self._stats is not None
|
| 155 |
+
|
| 156 |
+
# -- fit ---------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
def fit(self, X_train: pd.DataFrame, y_train: pd.Series) -> FittedStats:
|
| 159 |
+
"""Compute every statistic the serving path will need.
|
| 160 |
+
|
| 161 |
+
Returns FittedStats and nothing else -- not a transformed matrix. The caller
|
| 162 |
+
invokes transform(X_train) afterwards. That duplicates steps 1 and 2 and is worth
|
| 163 |
+
it: it makes "the training matrix is produced by the same code path as a single
|
| 164 |
+
live URL" true by construction rather than by inspection.
|
| 165 |
+
"""
|
| 166 |
+
X = X_train.copy()
|
| 167 |
+
|
| 168 |
+
# 1. Character frequency table over the training corpus' URLs.
|
| 169 |
+
char_prob = uf.calculate_char_prob(X, "URL")
|
| 170 |
+
|
| 171 |
+
# 2. Materialise the string-derived columns so later steps see a filled TLD to
|
| 172 |
+
# group by. This is a dry run of transform's first stage.
|
| 173 |
+
tld_prob_mean_series, tld_global_fill = uf.compute_tld_prob_statistics(X)
|
| 174 |
+
tld_skew = uf.scalar_float(X["TLDLegitimateProb"].skew())
|
| 175 |
+
tld_fill_method: FillMethod = "median" if abs(tld_skew) > 1 else "mean"
|
| 176 |
+
|
| 177 |
+
X = _apply_url_fill_chain(
|
| 178 |
+
X,
|
| 179 |
+
char_prob=char_prob,
|
| 180 |
+
tld_prob_mean=tld_prob_mean_series,
|
| 181 |
+
tld_prob_global_fill=tld_global_fill,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# 3/4. Numeric imputation. The threshold is 3 here and 1 for the TLD statistic
|
| 185 |
+
# above. The two are genuinely different and are not unified.
|
| 186 |
+
numeric_fill: list[NumericFill] = []
|
| 187 |
+
for col in schema.NUMERICAL_COLUMNS:
|
| 188 |
+
skewness = uf.scalar_float(X[col].skew())
|
| 189 |
+
method: FillMethod
|
| 190 |
+
if abs(skewness) > 3:
|
| 191 |
+
method = "median"
|
| 192 |
+
value = uf.scalar_float(X[col].median())
|
| 193 |
+
else:
|
| 194 |
+
method = "mean"
|
| 195 |
+
value = uf.scalar_float(X[col].mean())
|
| 196 |
+
numeric_fill.append(
|
| 197 |
+
NumericFill(column=col, method=method, value=value, skew=skewness)
|
| 198 |
+
)
|
| 199 |
+
X[col] = X[col].fillna(value)
|
| 200 |
+
|
| 201 |
+
# 5. The ordered categorical cascade.
|
| 202 |
+
cascade = self._fit_cascade(X)
|
| 203 |
+
for step in cascade:
|
| 204 |
+
X[step.column] = self._fill_cascade_step(X, step, count_fallbacks=False)
|
| 205 |
+
|
| 206 |
+
# 6. Clip bounds, computed after imputation so the quantiles are over the filled
|
| 207 |
+
# distribution.
|
| 208 |
+
clip_bounds = tuple(
|
| 209 |
+
ClipBound(
|
| 210 |
+
column=col,
|
| 211 |
+
lower=float(np.percentile(X[col].dropna(), 1.0)),
|
| 212 |
+
upper=float(np.percentile(X[col].dropna(), 99.0)),
|
| 213 |
+
)
|
| 214 |
+
for col in schema.NUMERICAL_COLUMNS
|
| 215 |
+
)
|
| 216 |
+
for b in clip_bounds:
|
| 217 |
+
X[b.column] = np.clip(X[b.column], b.lower, b.upper)
|
| 218 |
+
|
| 219 |
+
# 7. Scaler over the 30 numerics only.
|
| 220 |
+
X_features = X.drop(columns=list(schema.INTERMEDIATE_COLUMNS))
|
| 221 |
+
X_features = _validate_dtypes(X_features)
|
| 222 |
+
X_features = X_features[list(schema.FEATURE_ORDER)]
|
| 223 |
+
scaler_params = SubsetStandardScaler(schema.NUMERICAL_COLUMNS).fit(X_features)
|
| 224 |
+
X_scaled = SubsetStandardScaler.apply(X_features, scaler_params)
|
| 225 |
+
|
| 226 |
+
# 8. Naive-Bayes column drop, last because it needs the finished matrix.
|
| 227 |
+
nb_drop, nb_drop_reasons = naive_bayes_drop(X_scaled, y_train)
|
| 228 |
+
|
| 229 |
+
# 9. Page-feature fallbacks, derived from the fills computed above rather than
|
| 230 |
+
# computed again. Lifted out so the interface can display what it fell back to.
|
| 231 |
+
numeric_map = {f.column: f for f in numeric_fill}
|
| 232 |
+
cascade_map = {s.column: s for s in cascade}
|
| 233 |
+
html_fallbacks: list[HtmlFallback] = []
|
| 234 |
+
for col in (*schema.HTML_FEATURES, *schema.TITLE_HYBRID_FEATURES):
|
| 235 |
+
if col in numeric_map:
|
| 236 |
+
nf = numeric_map[col]
|
| 237 |
+
source: FallbackSource = (
|
| 238 |
+
"numeric_median" if nf.method == "median" else "numeric_mean"
|
| 239 |
+
)
|
| 240 |
+
html_fallbacks.append(
|
| 241 |
+
HtmlFallback(column=col, value=nf.value, source=source)
|
| 242 |
+
)
|
| 243 |
+
elif col in cascade_map:
|
| 244 |
+
html_fallbacks.append(
|
| 245 |
+
HtmlFallback(
|
| 246 |
+
column=col,
|
| 247 |
+
value=float(cascade_map[col].global_mode),
|
| 248 |
+
source="categorical_mode",
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
stats = FittedStats(
|
| 253 |
+
char_prob=char_prob,
|
| 254 |
+
tld_prob_mean={str(k): float(v) for k, v in tld_prob_mean_series.items()},
|
| 255 |
+
tld_prob_global_fill=float(tld_global_fill),
|
| 256 |
+
tld_skew=tld_skew,
|
| 257 |
+
tld_fill_method=tld_fill_method,
|
| 258 |
+
numeric_fill=tuple(numeric_fill),
|
| 259 |
+
categorical_cascade=cascade,
|
| 260 |
+
clip_bounds=clip_bounds,
|
| 261 |
+
scaler=scaler_params,
|
| 262 |
+
nb_drop=nb_drop,
|
| 263 |
+
nb_drop_reasons=nb_drop_reasons,
|
| 264 |
+
html_fallbacks=tuple(html_fallbacks),
|
| 265 |
+
feature_order=schema.FEATURE_ORDER,
|
| 266 |
+
numerical_columns=schema.NUMERICAL_COLUMNS,
|
| 267 |
+
continuous_columns=schema.CONTINUOUS_COLUMNS,
|
| 268 |
+
discrete_columns=schema.DISCRETE_COLUMNS,
|
| 269 |
+
categorical_columns_filtered=schema.CATEGORICAL_COLUMNS_FILTERED,
|
| 270 |
+
n_train_rows=int(len(X_train)),
|
| 271 |
+
)
|
| 272 |
+
self._stats = stats
|
| 273 |
+
return stats
|
| 274 |
+
|
| 275 |
+
def _fit_cascade(self, X: pd.DataFrame) -> tuple[CategoricalFillStep, ...]:
|
| 276 |
+
"""Learn the 18 mode tables, growing the group key one column per step.
|
| 277 |
+
|
| 278 |
+
The key grows monotonically: step 1 groups by HasObfuscation alone, step 2 by
|
| 279 |
+
HasObfuscation and the column step 1 just filled, and so on. So column n is
|
| 280 |
+
imputed conditioned on columns 1..n-1, and permuting the list changes every mode
|
| 281 |
+
table from step 2 onward. It is not a cosmetic ordering.
|
| 282 |
+
"""
|
| 283 |
+
steps: list[CategoricalFillStep] = []
|
| 284 |
+
groupby_cols: list[str] = list(schema.CATEGORICAL_FILL_INITIAL_GROUP_BY)
|
| 285 |
+
|
| 286 |
+
for col in schema.CATEGORICAL_COLUMNS_TO_FILL:
|
| 287 |
+
grouped = X.groupby(groupby_cols, dropna=False)[col].agg(_group_mode)
|
| 288 |
+
col_mode = X[col].mode()
|
| 289 |
+
global_mode = uf.scalar_float(col_mode.iloc[0]) if not col_mode.empty else 0.0
|
| 290 |
+
|
| 291 |
+
modes: dict[tuple[float, ...], float] = {}
|
| 292 |
+
for key, value in grouped.items():
|
| 293 |
+
if value is None or pd.isna(value):
|
| 294 |
+
continue
|
| 295 |
+
key_tuple = key if isinstance(key, tuple) else (key,)
|
| 296 |
+
if any(pd.isna(k) for k in key_tuple):
|
| 297 |
+
continue
|
| 298 |
+
modes[tuple(float(k) for k in key_tuple)] = float(value)
|
| 299 |
+
|
| 300 |
+
step = CategoricalFillStep(
|
| 301 |
+
column=col,
|
| 302 |
+
groupby_cols=tuple(groupby_cols),
|
| 303 |
+
modes=modes,
|
| 304 |
+
global_mode=global_mode,
|
| 305 |
+
)
|
| 306 |
+
steps.append(step)
|
| 307 |
+
X[col] = self._fill_cascade_step(X, step, count_fallbacks=False)
|
| 308 |
+
groupby_cols.append(col)
|
| 309 |
+
|
| 310 |
+
return tuple(steps)
|
| 311 |
+
|
| 312 |
+
# -- transform ---------------------------------------------------------
|
| 313 |
+
|
| 314 |
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
| 315 |
+
"""Apply the fitted statistics. Computes nothing.
|
| 316 |
+
|
| 317 |
+
Accepts a frame in the raw schema shape -- the same shape a row of the training
|
| 318 |
+
CSV has -- whether it holds 28,081 rows or 1. There is no branch on len(X).
|
| 319 |
+
"""
|
| 320 |
+
stats = self.stats
|
| 321 |
+
|
| 322 |
+
# Validate the input contract before touching anything. A missing feature column
|
| 323 |
+
# is a broken extractor, not an unknown value, and it must be reported by name
|
| 324 |
+
# rather than surfacing later as a bare KeyError from whichever stage happened to
|
| 325 |
+
# reach for it first.
|
| 326 |
+
required = {*stats.feature_order, *schema.INTERMEDIATE_COLUMNS}
|
| 327 |
+
missing = sorted(required - set(X.columns))
|
| 328 |
+
if missing:
|
| 329 |
+
raise FeatureContractError(f"input columns absent from frame: {missing}")
|
| 330 |
+
|
| 331 |
+
X = X.copy()
|
| 332 |
+
|
| 333 |
+
tld_prob_mean = pd.Series(stats.tld_prob_mean, dtype="float64")
|
| 334 |
+
|
| 335 |
+
# 1. The 26 row-local fill functions, with the three fitted statistics injected.
|
| 336 |
+
X = _apply_url_fill_chain(
|
| 337 |
+
X,
|
| 338 |
+
char_prob=stats.char_prob,
|
| 339 |
+
tld_prob_mean=tld_prob_mean,
|
| 340 |
+
tld_prob_global_fill=stats.tld_prob_global_fill,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# 2. Numeric imputation from recorded values.
|
| 344 |
+
for f in stats.numeric_fill:
|
| 345 |
+
X[f.column] = X[f.column].fillna(f.value)
|
| 346 |
+
|
| 347 |
+
# 3. Replay the cascade in recorded order.
|
| 348 |
+
for step in stats.categorical_cascade:
|
| 349 |
+
X[step.column] = self._fill_cascade_step(X, step, count_fallbacks=True)
|
| 350 |
+
|
| 351 |
+
# 4. Clip to recorded bounds.
|
| 352 |
+
for b in stats.clip_bounds:
|
| 353 |
+
X[b.column] = np.clip(X[b.column], b.lower, b.upper)
|
| 354 |
+
|
| 355 |
+
# 5. Drop the text intermediates.
|
| 356 |
+
X = X.drop(columns=[c for c in schema.INTERMEDIATE_COLUMNS if c in X.columns])
|
| 357 |
+
|
| 358 |
+
# 6. Reindex to the frozen order. A missing column is a broken contract, not an
|
| 359 |
+
# unknown value, so this raises rather than filling.
|
| 360 |
+
missing = sorted(set(stats.feature_order) - set(X.columns))
|
| 361 |
+
if missing:
|
| 362 |
+
raise FeatureContractError(f"feature columns absent from frame: {missing}")
|
| 363 |
+
X = X[list(stats.feature_order)]
|
| 364 |
+
|
| 365 |
+
# 7. Dtype validation, with the column lists passed in explicitly rather than
|
| 366 |
+
# closed over. A helper that closes over a module global is a train/serve skew
|
| 367 |
+
# bug waiting for someone to import the module in a different order.
|
| 368 |
+
X = _validate_dtypes(
|
| 369 |
+
X,
|
| 370 |
+
binary_columns=(*stats.categorical_columns_filtered, *stats.discrete_columns),
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
# 8. Scale the 30 numerics.
|
| 374 |
+
X = SubsetStandardScaler.apply(X, stats.scaler)
|
| 375 |
+
|
| 376 |
+
return X
|
| 377 |
+
|
| 378 |
+
def transform_matrix(self, X: pd.DataFrame) -> np.ndarray:
|
| 379 |
+
"""transform(), fixed to float32 at the boundary.
|
| 380 |
+
|
| 381 |
+
The dtype is pinned here so the KNN distance kernel never silently upcasts and
|
| 382 |
+
never sees a dtype it did not see during training.
|
| 383 |
+
"""
|
| 384 |
+
return self.transform(X).to_numpy(dtype=np.float32)
|
| 385 |
+
|
| 386 |
+
def _fill_cascade_step(
|
| 387 |
+
self, X: pd.DataFrame, step: CategoricalFillStep, *, count_fallbacks: bool
|
| 388 |
+
) -> pd.Series:
|
| 389 |
+
"""Fill one cascade column from its recorded mode table.
|
| 390 |
+
|
| 391 |
+
An unseen group key is expected, not exceptional. At step 18 the key is 18 columns
|
| 392 |
+
wide, so the training frame observed only a small fraction of the combinatorial
|
| 393 |
+
space. The defined behaviour is to fall back to the recorded global mode -- not to
|
| 394 |
+
raise, not to warn at the user, and under no circumstances to recompute the mode
|
| 395 |
+
from the incoming batch, which would reintroduce exactly the defect this module
|
| 396 |
+
exists to remove.
|
| 397 |
+
"""
|
| 398 |
+
col = X[step.column]
|
| 399 |
+
null_mask = col.isna()
|
| 400 |
+
if not null_mask.any():
|
| 401 |
+
return col
|
| 402 |
+
|
| 403 |
+
modes = step.modes
|
| 404 |
+
global_mode = step.global_mode
|
| 405 |
+
|
| 406 |
+
# Build every key at once rather than looping row by row. Row-wise .loc lookups on
|
| 407 |
+
# a 53-column frame cost enough that, across 18 cascade steps and the ~40% of
|
| 408 |
+
# 112,000 training rows that need filling, they dominate the whole fit. The
|
| 409 |
+
# arithmetic is unchanged: each key is still built only from its own row's values.
|
| 410 |
+
key_block = X.loc[null_mask, list(step.groupby_cols)]
|
| 411 |
+
key_values = key_block.to_numpy(dtype="float64", na_value=np.nan)
|
| 412 |
+
|
| 413 |
+
resolved = np.empty(len(key_values), dtype="float64")
|
| 414 |
+
missed = 0
|
| 415 |
+
for i, key_row in enumerate(key_values):
|
| 416 |
+
if np.isnan(key_row).any():
|
| 417 |
+
# A key component that is itself missing cannot address the table. It is a
|
| 418 |
+
# fallback, not an error -- the earlier steps of the cascade may leave a
|
| 419 |
+
# conditioning column unfilled on a sufficiently sparse row.
|
| 420 |
+
resolved[i] = global_mode
|
| 421 |
+
missed += 1
|
| 422 |
+
continue
|
| 423 |
+
value = modes.get(tuple(key_row.tolist()))
|
| 424 |
+
if value is None:
|
| 425 |
+
resolved[i] = global_mode
|
| 426 |
+
missed += 1
|
| 427 |
+
else:
|
| 428 |
+
resolved[i] = value
|
| 429 |
+
|
| 430 |
+
if count_fallbacks and missed:
|
| 431 |
+
self.fallback_counts[step.column] += missed
|
| 432 |
+
|
| 433 |
+
filled = col.copy()
|
| 434 |
+
filled.loc[null_mask] = resolved
|
| 435 |
+
return filled
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _validate_dtypes(
|
| 439 |
+
X: pd.DataFrame, binary_columns: tuple[str, ...] | None = None
|
| 440 |
+
) -> pd.DataFrame:
|
| 441 |
+
"""Coerce the integral columns to int, as the original did.
|
| 442 |
+
|
| 443 |
+
Column lists arrive as a parameter. The original closed over notebook globals here,
|
| 444 |
+
which is how the scaler silently ended up touching only 30 of 49 columns.
|
| 445 |
+
"""
|
| 446 |
+
if binary_columns is None:
|
| 447 |
+
binary_columns = (*schema.CATEGORICAL_COLUMNS_FILTERED, *schema.DISCRETE_COLUMNS)
|
| 448 |
+
|
| 449 |
+
X = X.copy()
|
| 450 |
+
cols = [c for c in binary_columns if c in X.columns]
|
| 451 |
+
if cols:
|
| 452 |
+
X[cols] = X[cols].astype("int64")
|
| 453 |
+
return X
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def naive_bayes_drop(
|
| 457 |
+
X: pd.DataFrame, y: pd.Series
|
| 458 |
+
) -> tuple[tuple[str, ...], dict[str, NbDropReason]]:
|
| 459 |
+
"""Columns Gaussian NB cannot use, with the reason recorded per column.
|
| 460 |
+
|
| 461 |
+
Two rules, ported from the original:
|
| 462 |
+
|
| 463 |
+
- a binary indicator with an empty (value, label) contingency cell, which would give
|
| 464 |
+
that cell zero likelihood;
|
| 465 |
+
- any column with zero standard deviation, whose Gaussian is degenerate.
|
| 466 |
+
|
| 467 |
+
The original computed this list and discarded it, so nothing downstream could say
|
| 468 |
+
which columns a served model had actually been fitted on. Here it is returned,
|
| 469 |
+
deduplicated with the first reason winning, and persisted.
|
| 470 |
+
"""
|
| 471 |
+
nb_df = X.join(y.rename(schema.TARGET_COLUMN))
|
| 472 |
+
reasons: dict[str, NbDropReason] = {}
|
| 473 |
+
ordered: list[str] = []
|
| 474 |
+
|
| 475 |
+
for f in schema.CATEGORICAL_COLUMNS_FILTERED:
|
| 476 |
+
if f not in X.columns:
|
| 477 |
+
continue
|
| 478 |
+
for value, label in [(x // 2, x % 2) for x in range(4)]:
|
| 479 |
+
empty = len(nb_df[(nb_df[f] == value) & (nb_df[schema.TARGET_COLUMN] == label)]) == 0
|
| 480 |
+
if empty and f not in reasons:
|
| 481 |
+
reasons[f] = "empty_contingency_cell"
|
| 482 |
+
ordered.append(f)
|
| 483 |
+
|
| 484 |
+
for col in X.columns:
|
| 485 |
+
if uf.scalar_float(X[col].std()) == 0.0 and col not in reasons:
|
| 486 |
+
reasons[col] = "zero_std"
|
| 487 |
+
ordered.append(col)
|
| 488 |
+
|
| 489 |
+
return tuple(ordered), reasons
|
phiusiil/schema.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The frozen feature contract.
|
| 2 |
+
|
| 3 |
+
Every column name, every column group, and the exact ordering of the 49 model features
|
| 4 |
+
live here and nowhere else. Nothing in the codebase may hardcode a column list; it imports
|
| 5 |
+
one of these constants instead.
|
| 6 |
+
|
| 7 |
+
Why this module is written defensively, with an assertion after every group: the original
|
| 8 |
+
notebook derived its column groups from whatever DataFrame happened to be in scope. The
|
| 9 |
+
scaler consequently touched only the 30 numeric columns and left the 19 binaries raw --
|
| 10 |
+
not by decision, but because ``numerical_columns`` was a notebook global that closed over
|
| 11 |
+
the training frame. Every recorded metric depends on that accident. Making the groups
|
| 12 |
+
explicit and asserting their sizes at import time means a typo fails immediately and
|
| 13 |
+
loudly, rather than silently changing what the models were fitted on.
|
| 14 |
+
|
| 15 |
+
Dataset column typos are real and preserved verbatim: ``NoOfDegitsInURL``,
|
| 16 |
+
``DegitRatioInURL``, ``SpacialCharRatioInURL``.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from typing import Final
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Raw CSV schema
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
#: The 56 columns of the raw CSV, in file order.
|
| 28 |
+
RAW_COLUMNS: Final[tuple[str, ...]] = (
|
| 29 |
+
"id",
|
| 30 |
+
"FILENAME",
|
| 31 |
+
"URL",
|
| 32 |
+
"URLLength",
|
| 33 |
+
"Domain",
|
| 34 |
+
"DomainLength",
|
| 35 |
+
"IsDomainIP",
|
| 36 |
+
"TLD",
|
| 37 |
+
"CharContinuationRate",
|
| 38 |
+
"TLDLegitimateProb",
|
| 39 |
+
"URLCharProb",
|
| 40 |
+
"TLDLength",
|
| 41 |
+
"NoOfSubDomain",
|
| 42 |
+
"HasObfuscation",
|
| 43 |
+
"NoOfObfuscatedChar",
|
| 44 |
+
"ObfuscationRatio",
|
| 45 |
+
"NoOfLettersInURL",
|
| 46 |
+
"LetterRatioInURL",
|
| 47 |
+
"NoOfDegitsInURL",
|
| 48 |
+
"DegitRatioInURL",
|
| 49 |
+
"NoOfEqualsInURL",
|
| 50 |
+
"NoOfQMarkInURL",
|
| 51 |
+
"NoOfAmpersandInURL",
|
| 52 |
+
"NoOfOtherSpecialCharsInURL",
|
| 53 |
+
"SpacialCharRatioInURL",
|
| 54 |
+
"IsHTTPS",
|
| 55 |
+
"LineOfCode",
|
| 56 |
+
"LargestLineLength",
|
| 57 |
+
"HasTitle",
|
| 58 |
+
"Title",
|
| 59 |
+
"DomainTitleMatchScore",
|
| 60 |
+
"URLTitleMatchScore",
|
| 61 |
+
"HasFavicon",
|
| 62 |
+
"Robots",
|
| 63 |
+
"IsResponsive",
|
| 64 |
+
"NoOfURLRedirect",
|
| 65 |
+
"NoOfSelfRedirect",
|
| 66 |
+
"HasDescription",
|
| 67 |
+
"NoOfPopup",
|
| 68 |
+
"NoOfiFrame",
|
| 69 |
+
"HasExternalFormSubmit",
|
| 70 |
+
"HasSocialNet",
|
| 71 |
+
"HasSubmitButton",
|
| 72 |
+
"HasHiddenFields",
|
| 73 |
+
"HasPasswordField",
|
| 74 |
+
"Bank",
|
| 75 |
+
"Pay",
|
| 76 |
+
"Crypto",
|
| 77 |
+
"HasCopyrightInfo",
|
| 78 |
+
"NoOfImage",
|
| 79 |
+
"NoOfCSS",
|
| 80 |
+
"NoOfJS",
|
| 81 |
+
"NoOfSelfRef",
|
| 82 |
+
"NoOfEmptyRef",
|
| 83 |
+
"NoOfExternalRef",
|
| 84 |
+
"label",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
#: Dropped before modelling: identifiers and the target.
|
| 88 |
+
DROPPED_IDENTIFIER_COLUMNS: Final[tuple[str, ...]] = ("id", "FILENAME")
|
| 89 |
+
TARGET_COLUMN: Final[str] = "label"
|
| 90 |
+
|
| 91 |
+
#: Text columns used to derive features and then dropped. They are not model inputs.
|
| 92 |
+
INTERMEDIATE_COLUMNS: Final[tuple[str, ...]] = ("URL", "Domain", "TLD", "Title")
|
| 93 |
+
|
| 94 |
+
#: The 53 columns the notebook's ``X_train`` carried, in file order.
|
| 95 |
+
WORKING_COLUMNS: Final[tuple[str, ...]] = tuple(
|
| 96 |
+
c for c in RAW_COLUMNS if c not in DROPPED_IDENTIFIER_COLUMNS and c != TARGET_COLUMN
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# The 49 model features
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
#: The frozen model input, in order. This ordering is the wire format between training
|
| 104 |
+
#: and serving: the matrix handed to every model has these columns, in exactly this
|
| 105 |
+
#: sequence. Reordering it silently changes what each model coefficient refers to.
|
| 106 |
+
FEATURE_ORDER: Final[tuple[str, ...]] = tuple(
|
| 107 |
+
c for c in WORKING_COLUMNS if c not in INTERMEDIATE_COLUMNS
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Column groups, reproduced exactly as the notebook defined them
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
#: Notebook cell 12. Includes the four text intermediates, which is why it has 23
|
| 115 |
+
#: members while the model-facing filtered list has 19.
|
| 116 |
+
CATEGORICAL_COLUMNS: Final[tuple[str, ...]] = (
|
| 117 |
+
"IsDomainIP",
|
| 118 |
+
"HasObfuscation",
|
| 119 |
+
"IsHTTPS",
|
| 120 |
+
"HasTitle",
|
| 121 |
+
"HasFavicon",
|
| 122 |
+
"Robots",
|
| 123 |
+
"IsResponsive",
|
| 124 |
+
"HasDescription",
|
| 125 |
+
"HasSocialNet",
|
| 126 |
+
"HasSubmitButton",
|
| 127 |
+
"HasHiddenFields",
|
| 128 |
+
"HasPasswordField",
|
| 129 |
+
"Bank",
|
| 130 |
+
"Pay",
|
| 131 |
+
"Crypto",
|
| 132 |
+
"HasCopyrightInfo",
|
| 133 |
+
"Domain",
|
| 134 |
+
"URL",
|
| 135 |
+
"TLD",
|
| 136 |
+
"Title",
|
| 137 |
+
"HasExternalFormSubmit",
|
| 138 |
+
"NoOfSelfRedirect",
|
| 139 |
+
"NoOfURLRedirect",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
#: Notebook cell 12: the same list without the four text intermediates. These are the
|
| 143 |
+
#: columns the scaler must pass through bit-identically.
|
| 144 |
+
CATEGORICAL_COLUMNS_FILTERED: Final[tuple[str, ...]] = tuple(
|
| 145 |
+
c for c in CATEGORICAL_COLUMNS if c not in INTERMEDIATE_COLUMNS
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
#: Notebook cell 104. Order is load-bearing, not cosmetic: the imputation cascade appends
|
| 149 |
+
#: each just-filled column to the group-by key before moving to the next one, so column
|
| 150 |
+
#: *n* is imputed conditioned on columns 1..n-1. Reordering this list changes the fill
|
| 151 |
+
#: values it produces.
|
| 152 |
+
CATEGORICAL_COLUMNS_TO_FILL: Final[tuple[str, ...]] = (
|
| 153 |
+
"IsDomainIP",
|
| 154 |
+
"IsHTTPS",
|
| 155 |
+
"HasTitle",
|
| 156 |
+
"IsResponsive",
|
| 157 |
+
"Pay",
|
| 158 |
+
"HasHiddenFields",
|
| 159 |
+
"Robots",
|
| 160 |
+
"Crypto",
|
| 161 |
+
"HasDescription",
|
| 162 |
+
"Bank",
|
| 163 |
+
"HasExternalFormSubmit",
|
| 164 |
+
"HasFavicon",
|
| 165 |
+
"HasSubmitButton",
|
| 166 |
+
"HasPasswordField",
|
| 167 |
+
"NoOfSelfRedirect",
|
| 168 |
+
"HasCopyrightInfo",
|
| 169 |
+
"NoOfURLRedirect",
|
| 170 |
+
"HasSocialNet",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
#: The initial group-by key for the cascade above (notebook cell 104).
|
| 174 |
+
CATEGORICAL_FILL_INITIAL_GROUP_BY: Final[tuple[str, ...]] = ("HasObfuscation",)
|
| 175 |
+
|
| 176 |
+
#: Notebook cell 13: everything in the working set that is not categorical. This is the
|
| 177 |
+
#: exact set the scaler touches, and no other.
|
| 178 |
+
NUMERICAL_COLUMNS: Final[tuple[str, ...]] = tuple(
|
| 179 |
+
c for c in WORKING_COLUMNS if c not in CATEGORICAL_COLUMNS
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
#: Notebook cell 14.
|
| 183 |
+
CONTINUOUS_COLUMNS: Final[tuple[str, ...]] = (
|
| 184 |
+
"CharContinuationRate",
|
| 185 |
+
"ObfuscationRatio",
|
| 186 |
+
"DegitRatioInURL",
|
| 187 |
+
"DomainTitleMatchScore",
|
| 188 |
+
"URLTitleMatchScore",
|
| 189 |
+
"TLDLegitimateProb",
|
| 190 |
+
"URLCharProb",
|
| 191 |
+
"LetterRatioInURL",
|
| 192 |
+
"SpacialCharRatioInURL",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
#: Notebook cell 15.
|
| 196 |
+
DISCRETE_COLUMNS: Final[tuple[str, ...]] = tuple(
|
| 197 |
+
c for c in NUMERICAL_COLUMNS if c not in CONTINUOUS_COLUMNS
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
# The 21 / 3 / 25 split by what a feature needs in order to be computed
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
#: Derivable from the URL string alone. Available even when the fetch fails.
|
| 205 |
+
URL_ONLY_FEATURES: Final[tuple[str, ...]] = (
|
| 206 |
+
"URLLength",
|
| 207 |
+
"DomainLength",
|
| 208 |
+
"IsDomainIP",
|
| 209 |
+
"CharContinuationRate",
|
| 210 |
+
"TLDLegitimateProb",
|
| 211 |
+
"URLCharProb",
|
| 212 |
+
"TLDLength",
|
| 213 |
+
"NoOfSubDomain",
|
| 214 |
+
"HasObfuscation",
|
| 215 |
+
"NoOfObfuscatedChar",
|
| 216 |
+
"ObfuscationRatio",
|
| 217 |
+
"NoOfLettersInURL",
|
| 218 |
+
"LetterRatioInURL",
|
| 219 |
+
"NoOfDegitsInURL",
|
| 220 |
+
"DegitRatioInURL",
|
| 221 |
+
"NoOfEqualsInURL",
|
| 222 |
+
"NoOfQMarkInURL",
|
| 223 |
+
"NoOfAmpersandInURL",
|
| 224 |
+
"NoOfOtherSpecialCharsInURL",
|
| 225 |
+
"SpacialCharRatioInURL",
|
| 226 |
+
"IsHTTPS",
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
#: Have a URL-side implementation, but consume a Title that only exists once the page has
|
| 230 |
+
#: been fetched and parsed. With no successful fetch these fall through to imputation.
|
| 231 |
+
TITLE_HYBRID_FEATURES: Final[tuple[str, ...]] = (
|
| 232 |
+
"HasTitle",
|
| 233 |
+
"DomainTitleMatchScore",
|
| 234 |
+
"URLTitleMatchScore",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
#: Require the fetched page. Slightly more than half the model's inputs -- which is why
|
| 238 |
+
#: single-URL inference fetches at all, and why it must abstain when the fetch fails.
|
| 239 |
+
HTML_FEATURES: Final[tuple[str, ...]] = tuple(
|
| 240 |
+
c for c in FEATURE_ORDER if c not in URL_ONLY_FEATURES and c not in TITLE_HYBRID_FEATURES
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
# Labels
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
|
| 247 |
+
#: Class 0 is phishing and is the positive class throughout this codebase. Class 1 is
|
| 248 |
+
#: legitimate. Every confusion matrix is oriented rows = true (0, 1), cols = pred (0, 1).
|
| 249 |
+
PHISHING_LABEL: Final[int] = 0
|
| 250 |
+
LEGITIMATE_LABEL: Final[int] = 1
|
| 251 |
+
|
| 252 |
+
#: A constant "always legitimate" predictor scores this on the full dataset. Any accuracy
|
| 253 |
+
#: figure is meaningless unless read against it.
|
| 254 |
+
MAJORITY_BASELINE_ACCURACY: Final[float] = 0.9248
|
| 255 |
+
|
| 256 |
+
#: Provenance values recorded per feature for a single inference.
|
| 257 |
+
PROVENANCE_URL: Final[str] = "url"
|
| 258 |
+
PROVENANCE_SCRAPED: Final[str] = "scraped"
|
| 259 |
+
PROVENANCE_IMPUTED: Final[str] = "imputed"
|
| 260 |
+
PROVENANCE_DEMOTED: Final[str] = "demoted"
|
| 261 |
+
PROVENANCE_VALUES: Final[frozenset[str]] = frozenset(
|
| 262 |
+
{PROVENANCE_URL, PROVENANCE_SCRAPED, PROVENANCE_IMPUTED, PROVENANCE_DEMOTED}
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# ---------------------------------------------------------------------------
|
| 266 |
+
# Import-time assertions. A typo above fails here, not three phases downstream.
|
| 267 |
+
# ---------------------------------------------------------------------------
|
| 268 |
+
|
| 269 |
+
assert len(RAW_COLUMNS) == 56, len(RAW_COLUMNS)
|
| 270 |
+
assert len(set(RAW_COLUMNS)) == 56, "duplicate column name in RAW_COLUMNS"
|
| 271 |
+
assert len(WORKING_COLUMNS) == 53, len(WORKING_COLUMNS)
|
| 272 |
+
assert len(FEATURE_ORDER) == 49, len(FEATURE_ORDER)
|
| 273 |
+
|
| 274 |
+
assert len(CATEGORICAL_COLUMNS) == 23, len(CATEGORICAL_COLUMNS)
|
| 275 |
+
assert len(CATEGORICAL_COLUMNS_FILTERED) == 19, len(CATEGORICAL_COLUMNS_FILTERED)
|
| 276 |
+
assert len(CATEGORICAL_COLUMNS_TO_FILL) == 18, len(CATEGORICAL_COLUMNS_TO_FILL)
|
| 277 |
+
assert len(NUMERICAL_COLUMNS) == 30, len(NUMERICAL_COLUMNS)
|
| 278 |
+
assert len(CONTINUOUS_COLUMNS) == 9, len(CONTINUOUS_COLUMNS)
|
| 279 |
+
assert len(DISCRETE_COLUMNS) == 21, len(DISCRETE_COLUMNS)
|
| 280 |
+
|
| 281 |
+
assert len(URL_ONLY_FEATURES) == 21, len(URL_ONLY_FEATURES)
|
| 282 |
+
assert len(TITLE_HYBRID_FEATURES) == 3, len(TITLE_HYBRID_FEATURES)
|
| 283 |
+
assert len(HTML_FEATURES) == 25, len(HTML_FEATURES)
|
| 284 |
+
|
| 285 |
+
# The scaler's scope and the pass-through set must exactly partition the 49 features.
|
| 286 |
+
assert set(NUMERICAL_COLUMNS) | set(CATEGORICAL_COLUMNS_FILTERED) == set(FEATURE_ORDER)
|
| 287 |
+
assert not (set(NUMERICAL_COLUMNS) & set(CATEGORICAL_COLUMNS_FILTERED))
|
| 288 |
+
|
| 289 |
+
# The three-way availability split must also partition the 49, with no overlap.
|
| 290 |
+
assert (
|
| 291 |
+
set(URL_ONLY_FEATURES) | set(TITLE_HYBRID_FEATURES) | set(HTML_FEATURES)
|
| 292 |
+
== set(FEATURE_ORDER)
|
| 293 |
+
)
|
| 294 |
+
assert not (set(URL_ONLY_FEATURES) & set(TITLE_HYBRID_FEATURES))
|
| 295 |
+
assert not (set(URL_ONLY_FEATURES) & set(HTML_FEATURES))
|
| 296 |
+
assert not (set(TITLE_HYBRID_FEATURES) & set(HTML_FEATURES))
|
| 297 |
+
|
| 298 |
+
assert set(CONTINUOUS_COLUMNS) <= set(NUMERICAL_COLUMNS)
|
| 299 |
+
assert set(CATEGORICAL_COLUMNS_TO_FILL) <= set(CATEGORICAL_COLUMNS_FILTERED)
|
| 300 |
+
assert set(CATEGORICAL_FILL_INITIAL_GROUP_BY) <= set(CATEGORICAL_COLUMNS_FILTERED)
|
| 301 |
+
|
| 302 |
+
# The cascade fills every categorical except the one it initially groups by.
|
| 303 |
+
assert set(CATEGORICAL_COLUMNS_FILTERED) - set(CATEGORICAL_COLUMNS_TO_FILL) == set(
|
| 304 |
+
CATEGORICAL_FILL_INITIAL_GROUP_BY
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
__all__ = [
|
| 308 |
+
"CATEGORICAL_COLUMNS",
|
| 309 |
+
"CATEGORICAL_COLUMNS_FILTERED",
|
| 310 |
+
"CATEGORICAL_COLUMNS_TO_FILL",
|
| 311 |
+
"CATEGORICAL_FILL_INITIAL_GROUP_BY",
|
| 312 |
+
"CONTINUOUS_COLUMNS",
|
| 313 |
+
"DISCRETE_COLUMNS",
|
| 314 |
+
"DROPPED_IDENTIFIER_COLUMNS",
|
| 315 |
+
"FEATURE_ORDER",
|
| 316 |
+
"HTML_FEATURES",
|
| 317 |
+
"INTERMEDIATE_COLUMNS",
|
| 318 |
+
"LEGITIMATE_LABEL",
|
| 319 |
+
"MAJORITY_BASELINE_ACCURACY",
|
| 320 |
+
"NUMERICAL_COLUMNS",
|
| 321 |
+
"PHISHING_LABEL",
|
| 322 |
+
"PROVENANCE_DEMOTED",
|
| 323 |
+
"PROVENANCE_IMPUTED",
|
| 324 |
+
"PROVENANCE_SCRAPED",
|
| 325 |
+
"PROVENANCE_URL",
|
| 326 |
+
"PROVENANCE_VALUES",
|
| 327 |
+
"RAW_COLUMNS",
|
| 328 |
+
"TARGET_COLUMN",
|
| 329 |
+
"TITLE_HYBRID_FEATURES",
|
| 330 |
+
"URL_ONLY_FEATURES",
|
| 331 |
+
"WORKING_COLUMNS",
|
| 332 |
+
]
|
pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "fetiai-v1-phiusiil-binclf-knn-scratch-500k"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "KNN (from scratch) for PhiUSIIL phishing URL classification, served over HTTP"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
authors = [
|
| 13 |
+
{ name = "Kelompok 16 ITB" },
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
# Runtime dependencies are pinned in requirements.txt. This list carries only the lower
|
| 17 |
+
# bounds the code actually depends on.
|
| 18 |
+
dependencies = [
|
| 19 |
+
"fastapi>=0.110",
|
| 20 |
+
"numpy>=1.26",
|
| 21 |
+
"pandas>=2.1",
|
| 22 |
+
"pydantic>=2.6",
|
| 23 |
+
"uvicorn>=0.27"
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[tool.setuptools]
|
| 27 |
+
packages = ["phiusiil", "phiusiil.features", "phiusiil.models", "phiusiil.preprocess", "server"]
|
| 28 |
+
|
| 29 |
+
[tool.pytest.ini_options]
|
| 30 |
+
pythonpath = ["."]
|
| 31 |
+
testpaths = ["tests"]
|