You get an email from your bank. The link says www.standardcharterbank-login.com. Looks fine, right?
It isn’t. And that’s exactly the problem.
About 1.4 million phishing sites go live every month, and Google alone blocks roughly 100 million phishing emails a day (AAG IT). Most of these scams work because the fake URL looks almost right.
So I asked a simple question: can a machine learn to tell a fake website from a real one, just by looking at its URL and a few page signals?
Turns out it can. In this post I’ll walk you through exactly how I built it, step by step, so you can build your own.
TL;DR
Phishers copy the look of a real site. But they can’t easily copy its history. A brand-new domain, a weird long URL, zero search ranking: these leave fingerprints.
Before building anything, I explored the data to see which fingerprints actually matter. Here’s what stood out:
Rule of thumb: a long URL on a young, unranked, low-traffic domain deserves a second look.
I used the Web Page Phishing Detection dataset on Kaggle. It’s based on research by Hannousse and Yahiouche (2021), so it’s a solid, citable source.
The features fall into three groups:
A balanced dataset is a gift. It means accuracy is a fair metric, and you don’t need tricks like oversampling.
I ran everything on a MacBook Air M1 (8 GB RAM) with Python 3.10, PySpark 3.4.1 and Jupyter. If your laptop can run Chrome, it can run this.
Spark runs on the JVM, so install Java 8 first, then check it:
java -version
Next, find where Java lives and point JAVA_HOME at it. On newer Macs the default shell is zsh, so the setting goes in ~/.zprofile (not .bash_profile):
/usr/libexec/java_home
I used Conda, which keeps things tidy:
conda install -c conda-forge pysparkpyspark --version
If you see the Spark logo and a version number, you’re good.
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("URLClassification").getOrCreate()
data = spark.read.csv("dataset_phishing.csv", header=True, inferSchema=True)data.printSchema()
inferSchema=True lets Spark guess each column's type, so numbers come in as numbers, not strings.
Don’t skip this. EDA is where you find out what the model should learn. I checked three things first:
Then I looked for features that move together, using a correlation heatmap:
import seaborn as snsimport matplotlib.pyplot as plt
corr = data.drop("url", "status").toPandas().corr()plt.figure(figsize=(32, 20))sns.heatmap(corr, cmap="coolwarm", center=0)plt.show()
Tip: toPandas() pulls everything into memory. Fine for 6 MB, risky for 6 GB. On big data, sample first.
The strongest pair was longest_word_path with longest_words_raw, which makes sense since one is part of the other.
Notebook charts are fine for you. For anyone else, I rebuilt them in Tableau.
In Tableau Prep, I swapped the 0/1 codes for human words: 0 became "Legit" and 1 became "Phishing", and other flags became "Yes"/"No". Small change, much easier dashboards. You can explore the result on Tableau Public.
MLlib has two rules: labels must be numbers, and all features must sit in one vector column. Two tools handle that:
from pyspark.ml.feature import StringIndexer, VectorAssembler
feature_cols = [c for c in data.columns if c not in ("url", "status")]
target_indexer = StringIndexer(inputCol="status", outputCol="label")assembler = VectorAssembler(inputCols=feature_cols, outputCol="features")
Why guess which algorithm is best when you can test them all? Put them in a dictionary:
from pyspark.ml.classification import ( DecisionTreeClassifier, RandomForestClassifier, NaiveBayes, GBTClassifier, LinearSVC)
classifiers = { "DecisionTree": DecisionTreeClassifier(labelCol="label", featuresCol="features"), "RandomForest": RandomForestClassifier(labelCol="label", featuresCol="features", numTrees=100, maxDepth=10), "NaiveBayes": NaiveBayes(smoothing=1.0, modelType="gaussian"), "GBT": GBTClassifier(labelCol="label", featuresCol="features", maxIter=10), "LinearSVC": LinearSVC(labelCol="label", featuresCol="features"),}
Then wrap each one in a Pipeline, split 70/30, train and score:
from pyspark.ml import Pipelinefrom pyspark.ml.evaluation import ( BinaryClassificationEvaluator, MulticlassClassificationEvaluator)
train, test = data.randomSplit([0.7, 0.3], seed=123)results = []
for name, clf in classifiers.items(): model = Pipeline(stages=[target_indexer, assembler, clf]).fit(train) preds = model.transform(test)
auc = BinaryClassificationEvaluator(labelCol="label").evaluate(preds) auprc = BinaryClassificationEvaluator(labelCol="label", metricName="areaUnderPR").evaluate(preds) acc = MulticlassClassificationEvaluator(labelCol="label", metricName="accuracy").evaluate(preds) f1 = MulticlassClassificationEvaluator(labelCol="label", metricName="f1").evaluate(preds)
results.append((name, round(auc, 2), round(auprc, 2), round(acc, 2), round(f1, 2)))
Tip: fix the seed so every model sees the exact same train/test split. Otherwise you're comparing luck, not algorithms.
89 features is a lot. Could the top 10 do just as well? Chi-Square (χ²) scores each feature by how strongly it relates to the label. It needs non-negative inputs, which suits this mostly 0/1 data.
from pyspark.ml.feature import ChiSqSelector
selector = ChiSqSelector(numTopFeatures=10, featuresCol="features", outputCol="selected_features")
I ran the same loop again with the selector added to each pipeline. Keep this step in mind. It comes back in the results.
Here’s how all five models scored on the 30% test set:
Model AUC AUPRC Accuracy F1Random Forest 0.99 0.99 0.96 0.96Linear SVC 0.98 0.98 0.94 0.94Gradient Boosted Trees 0.98 0.98 0.93 0.93Decision Tree 0.90 0.93 0.92 0.92Naive Bayes 0.65 0.68 0.76 0.76
Random Forest labeled a URL correctly 96 times out of 100. That matches earlier research too (Subasi et al., 2017; Hutchinson et al., 2018).
Why does it work so well here? Phishing signals combine in messy ways: a long URL alone is fine, but a long URL plus a young domain plus zero traffic is not. Hundreds of trees voting together are great at catching those combinations.
Naive Bayes struggled because it assumes every feature is independent. Here they clearly aren’t (URL length and word length rise together, for example).
With Chi-Square feature selection added, every model scored exactly the same, to two decimal places. My first conclusion was that feature selection simply didn’t help.
But identical numbers across five different models is suspicious. Look at the code again:
selector = ChiSqSelector(..., outputCol="selected_features")RandomForestClassifier(labelCol="label", featuresCol="features") # ← still reads all 89
The selector wrote its top 10 features into selected_features. But every classifier was still reading from features. So the selection ran, and then got ignored.
The fix is one argument:
RandomForestClassifier(labelCol="label", featuresCol="selected_features")
Lesson: when a change produces exactly the same result, don’t celebrate or shrug. Check that the change was actually wired in.
If I take this further, I’ll fix the Chi-Square wiring and compare 10 vs 89 features properly, add real cross-validation with CrossValidator, and wrap the Random Forest in a small API that checks any URL you paste in.
If this helped, give it a clap and follow for more hands-on data science. And next time a link looks almost right, check the URL twice.
Phishing Website Detection with Machine Learning: A Step-by-Step PySpark Guide was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.