faizath commited on
Commit
9094364
·
verified ·
1 Parent(s): f16df96

feat(phiusiil): vendor the scoring path from the full application

Browse files

The 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 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"]