#1 in MLP Project – Code, Chaos, and a Bit of Madness
This is my first time writing and sharing something like this publicly. But after a wild ride in the Machine Learning Practice (MLP) Project at IIT Madras—where I managed to finish Rank #1 out of 1700+ students—I felt it was worth putting my work out there.

In this post, I’m sharing my complete journey—what worked, what bombed, what I learned, and how I tackled the competition from start to finish. If you're into machine learning, Kaggle-style comps, or just here for a good story—you’re in the right place.
Introduction
This project was part of the Machine Learning Practice (MLP) course at IIT Madras. It was designed as a full-fledged Kaggle-style competition where over 1700 students participated. The task? Predict whether a system is likely to get infected by malware, using telemetry data collected by antivirus software.
We were provided with three main files:
You can download the Datasets here: GitHub
Importing Libraries
I started with the usual suspects — numpy, pandas and some plotting friends like matplotlib and seaborn. Obviously, I didn’t import everything at once. Like any true coder, I added more only when the errors started shouting at me.
Eventually, I ended up with a pretty packed import block that looked like I was preparing for war!
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import chi2_contingency
pd.set_option('display.max_rows', 100)
from sklearn.preprocessing import MinMaxScaler, OrdinalEncoder, OneHotEncoder
from category_encoders import BinaryEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split, RandomizedSearchCV
from sklearn.linear_model import LogisticRegression, SGDClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.naive_bayes import CategoricalNB
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from sklearn.ensemble import VotingClassifier
from sklearn.metrics import ConfusionMatrixDisplay, classification_report
Initial Exploration
Loading the Data
The first step was simple — load the CSVs. No rocket science here, just the classic pandas.read_csv() and a prayer that the paths were correct on the first try.
train = pd.read_csv('/kaggle/input/System-Threat-Forecaster/train.csv')
test = pd.read_csv('/kaggle/input/System-Threat-Forecaster/test.csv')
train.head(5)
Quick Overview
After loading, I checked the structure of the dataset —
- 100,000 rows in the training set
- 76 Columns, including the
target - 47 Numerical Features
- 28 Categorical Features
- 33 Features with missing values, but each has less than 1% missing, totally manageable!
Honestly? Not too messy—more like a college student’s room than a full crime scene.
def initial_exploration(dataset):
print(f"Shape of Dataset: {dataset.shape}")
feature_matrix = dataset.drop(columns=['target'])
label_vector = dataset['target']
numerical_features = feature_matrix.select_dtypes(include='number').columns
categorical_features = feature_matrix.select_dtypes(include='object').columns
print(f"Numerical Features: {len(numerical_features)}")
print(f"Categorical Features: {len(categorical_features)}")
nan_features = dataset.columns[dataset.isna().sum() > 0]
print(f"Features with NaN: {len(nan_features)}")
return feature_matrix, label_vector, numerical_features, categorical_features
X_train, y_train, numerical_features, categorical_features = initial_exploration(train)
def null_values_analyze(dataset):
custom_df = dataset.isna().sum()
custom_df = custom_df[custom_df > 0].to_frame(name='NaN Count')
custom_df['NaN %'] = (custom_df['NaN Count'] / dataset.shape[0]) * 100
print(custom_df)
null_values_analyze(train)
Exploratory Data Analysis
Before diving into model building, I needed to understand the battlefield—aka, the dataset. Exploratory Data Analysis (EDA) helped identify patterns, suspicious features, potential data issues, and hidden gold mines.
Numerical Features Overview
Time to zoom into the numerical features. Some of them were helpful, some were just there for moral support.
-
Useless Columns:
IsBetaUser,AutoSampleSubmissionEnabled, andIsFlightsDisabledhad only a single unique value. Basically the interns of the dataset. -
Binary Gang:
Many columns like
IsPassiveModeEnabled,IsSystemProtected,SMode,FirewallEnabled,HasOpticalDiskDrive,IsPortableOS,IsSecureBootEnabled,IsVirtualDevice,IsTouchEnabled,IsPenCapable,IsAlwaysOnAlwaysConnectedCapable, andIsGamerwere binary—great for modeling, not so great for insight. -
Genuinely Numeric:
Features like
ProcessorCoreCount,PrimaryDiskCapacityMB,SystemVolumeCapacityMB,TotalPhysicalRAMMB,PrimaryDisplayDiagonalInches,PrimaryDisplayResolutionHorizontal, andPrimaryDisplayResolutionVerticalwere actual continuous numerical variables. Finally, some real MVPs. -
Important Distribution Patterns :
TotalPhysicalRAMMBis right-skewed — most systems had lower RAM, with fewer high-end machines hogging the upper range.PrimaryDisplayResolutionHorizontalandPrimaryDisplayResolutionVerticalclustered around common resolutions like 1366×768 and 1920×1080 — standard display gang.CountryIDandCityIDhad broad, fairly uniform distributions, suggesting data came from a wide variety of locations—a nice bonus for generalization.
-
Interesting Correlations :
PrimaryDisplayResolutionHorizontalandPrimaryDisplayResolutionVerticalare 0.75 correlated, which is logical since screen dimensions often scale together.OSUILocaleIDandOSInstallLanguageIDhave 0.99 correlation — twins, basically.OSBuildNumberOnlyandOSBuildRevisionOnlyshow 0.95 correlation.TotalPhysicalRAMMBcorrelates withPrimaryDiskCapacityMB, indicating rich devices are rich in everything.
X_train[numerical_features].describe()
Visualizing Outliers with Boxplots
plt.figure(figsize=(15, 40))
sns.boxplot(data=MinMaxScaler().fit_transform(train[numerical_features]), color="royalblue", orient="h", width=0.6)
plt.title("Box Plots of Numerical Features", fontweight="bold", fontsize=20)
plt.xlabel("Value", fontweight="bold", fontsize=12)
plt.ylabel("Numerical Variables", fontweight="bold", fontsize=20)
plt.yticks(ticks=list(range(len(numerical_features))), labels=numerical_features, fontsize=13)
plt.show()
Distribution of Numerical Features
fig, axes = plt.subplots(16,3, figsize=(15,60))
axes = axes.flatten()
for i, feature in enumerate(numerical_features):
X_train[feature].plot(kind="hist", ax=axes[i], bins=12, color='royalblue', alpha=0.8)
axes[i].set_title(f"{feature} Distribution", fontweight="bold")
axes[i].set_ylabel("Frequency")
fig.tight_layout(pad=3.0)
plt.show()
Correlation Heatmap for Numerical Features
plt.figure(figsize=(50, 40))
sns.heatmap(X_train[numerical_features].corr(), annot=True, cmap='coolwarm')
plt.show()
Categorical Features Overview
After taming the numerical features, it was time to dive into the land of categories — the drama queens of any dataset.
-
Dominant Classes :
Platform Type:Windows 10 dominates the dataset (>95% of systems), with minimal presence of Windows 7, Windows 8, and Windows 2016.OS Architecture:AMD64 (64-bit) architecture is the majority (>90%), with minimal x86 (32-bit) and ARM64 systems.Device Family:Windows Desktop systems make up the vast majority, with very few Windows Server devices.
-
Redundancy Alert :
Product Name,OS VersionandPlatform Typeare tightly coupled — (99%) correlation. If you’ve seen one, you’ve seen them all.ProcessorandArchitecturehave perfect correlation (1.00). Makes sense—you’re not running 64-bit Windows on a potato CPU.
Distribution of Categorical Features
filtered_features = [col for col in categorical_features if X_train[col].nunique() <= 10]
fig, axes = plt.subplots(len(filtered_features) // 2 + 1, 2, figsize=(14, len(filtered_features) * 1.5))
axes = axes.flatten()
for i, feature in enumerate(filtered_features):
sns.countplot(y=X_train[feature], ax=axes[i], order=X_train[feature].value_counts().index, color="royalblue", alpha=0.8)
axes[i].set_title(f"{feature} Distribution", fontweight="bold")
axes[i].set_xlabel("Count")
axes[i].set_ylabel("")
fig.tight_layout(pad=2.0)
plt.show()
Correlation between Categorical Features (Cramér’s V Style)
def cramers_v(x, y):
contingency_table = pd.crosstab(x, y)
chi2, _, _, _ = chi2_contingency(contingency_table)
n = contingency_table.sum().sum()
r, k = contingency_table.shape
return np.sqrt(chi2 / (n * (min(r, k) - 1)))
filtered_features = [col for col in categorical_features if X_train[col].nunique() <= 10]
categorical_corr = pd.DataFrame(index=filtered_features, columns=filtered_features, dtype=float)
for i, col1 in enumerate(filtered_features):
for j, col2 in enumerate(filtered_features):
if j >= i:
if col1 == col2:
categorical_corr.loc[col1, col2] = 1.0
else:
categorical_corr.loc[col1, col2] = cramers_v(X_train[col1], X_train[col2])
categorical_corr.loc[col2, col1] = categorical_corr.loc[col1, col2]
plt.figure(figsize=(30, 15))
sns.heatmap(categorical_corr, annot=True, fmt=".2f", cmap='coolwarm', linewidths=0.5, square=True)
plt.title("Categorical Feature Correlation Heatmap (Cramér's V)", fontsize=14, fontweight="bold")
plt.xticks(rotation=90)
plt.yticks(rotation=0)
plt.show()
Target Variable Analysis
Before moving forward with modeling, I needed to verify if the target variable was balanced—or if I was about to walk into an imbalanced data nightmare.
- The classes were almost perfectly balanced. Around 51% vs 49%.
- No class dominates, which is a dream scenario in binary classification.
- That means: No need for oversampling, undersampling, synthetic data generation, or praying to the ML gods.
labels = ['Class 1', 'Class 0']
sizes = y_train.value_counts()
colors = ['skyblue', 'salmon']
explode = (0.1, 0)
plt.figure(figsize=(4,4))
plt.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors, explode=explode, shadow=True, startangle=140)
plt.title('Distribution of target')
plt.show()
Data Preprocessing
Alright, EDA gave me all the gossip: who’s fake, who’s flaky, and who’s actually useful. Then came the cleanup — like Mom before Diwali. No mercy, just dropping useless columns, engineering new ones, handling nulls, and encoding everything into a machine-learning-friendly format. Total sanskari data now.
Removing Columns
- MachineID: just a random identifier, not useful for predictions.
- IsBetaUser, AutoSampleSubmissionEnabled, IsFlightsDisabled: Each had only one unique value. Why even show up?
- OSUILocaleID: 99% correlated with OSInstallLanguageID (BFFs)
- OSBuildNumberOnly: 95% correlated with OSBuildNumber (BFFs)
- Dropping additional features that are binary but have a single dominant value, as well as some features that are unlikely to be relevant.
drop_cols = ['AutoSampleSubmissionEnabled', 'IsBetaUser', 'IsFlightsDisabled',
'OSBuildNumberOnly', 'MachineID', 'ProductName', 'PlatformType', 'DeviceFamily',
'OSUILocaleID', 'OSProductSuite', 'LocaleEnglishNameID', 'OSBuildRevisionOnly',
'OSBuildNumber', 'HasTpm', 'GeoRegionID', 'CountryID']
X_train_dropped = X_train.drop(columns=drop_cols)
test_dropped = test.drop(columns=drop_cols)
Feature Engineering
- DateAS - DateOS: I had two dates: DateAS (Antivirus Date) and DateOS (OS Installation Date). On their own? Boring. But subtract one from the other? Boom — I got something meaningful, How long the antivirus came after the OS was installed? Turns out, Users who updated antivirus after the OS install were more likely to detect malware. Makes sense—defense came late.
- AppVersion: AppVersion was something like 4.18.2001.10 . Cool for humans, not great for models. I just grabbed the second part (18) to turn it into a clean integer.
But hold up, this dataset also had other version like columns: SignatureVersion, NumericOSVersion, OSVersion, EngineVersion! These also looked like version numbers. So naturally, I tried. Trust me, I did. But here’s the twist...
Breaking them down didn’t help the model much. Maybe because the version formats were inconsistent, or because too many micro-versions created noise. So instead of dissecting them, I passed them through an Ordinal Encoder!
# Feature Engineering 1
X_train_dropped['DiffOS'] = pd.to_datetime(X_train_dropped['DateAS']) - pd.to_datetime(X_train_dropped['DateOS'])
X_train_dropped['DiffOS'] = X_train_dropped['DiffOS'].map(lambda x: x.days//7)
test_dropped['DiffOS'] = pd.to_datetime(test_dropped['DateAS']) - pd.to_datetime(test_dropped['DateOS'])
test_dropped['DiffOS'] = test_dropped['DiffOS'].map(lambda x: x.days//7)
del X_train_dropped['DateAS'], X_train_dropped['DateOS']
del test_dropped['DateAS'], test_dropped['DateOS']
# Feature Engineering 2
X_train_dropped['AppVersion'] = X_train_dropped['AppVersion'].map(lambda x: int(x.split('.')[1]))
test_dropped['AppVersion'] = test_dropped['AppVersion'].map(lambda x: int(x.split('.')[1]))
Handling Missing Values
Since almost all features represent some kind of category (even if they are given as numerical), it's better to impute them with the most frequent value.
most_frequent_cols = ['SystemVolumeCapacityMB', 'InternalBatteryNumberOfCharges',
'RealTimeProtectionState', 'AntivirusConfigID', 'NumAntivirusProductsInstalled',
'NumAntivirusProductsEnabled', 'CityID', 'IEVersionID', 'ProcessorCoreCount',
'EnableLUA', 'OEMNameID', 'OEMModelID', 'ProcessorManufacturerID', 'PrimaryDiskCapacityMB',
'ProcessorModelID', 'OSInstallLanguageID', 'FirmwareManufacturerID', 'FirmwareVersionID',
'RegionIdentifier', 'PrimaryDisplayDiagonalInches', 'PrimaryDisplayResolutionHorizontal',
'PrimaryDisplayResolutionVertical', 'DiffOS', 'TotalPhysicalRAMMB']
Feature Encoding & Scaling
Now that the data was trimmed, cleaned, and slightly smarter (thanks to feature engineering), it was time to translate everything into a format the models can actually understand—aka, numbers. Because sadly, scikit-learn doesn’t speak “WindowsDesktop.”
-
Binary Encoder:
These were things like
IsGamer,IsVirtualDeviceetc. Pure 0 or 1 type features. For these, I used a Binary Encoder to avoid breaking the column into 2 separate ones like One-Hot Enocoder does. Cleaner and more memory-efficient. -
OrdinalEncoder:
Columns like
OSVersion,Processor,OSEditionetc. had many unique values but still some kind of natural order (like newer versions being higher). For those, I used Ordinal Encoding. Why not OneHot? Because:- OneHot would explode the feature space (too many dummies).
- These columns had thousands of categories in some cases.
- Ordinal was faster, lighter, and honestly—performed better in testing.
binary_cols = ['IsPassiveModeEnabled', "IsSystemProtected", "SMode", "FirewallEnabled",
"HasOpticalDiskDrive", "IsPortableOS", "IsSecureBootEnabled", "IsVirtualDevice",
"IsTouchEnabled", "IsPenCapable", "IsAlwaysOnAlwaysConnectedCapable", "IsGamer"]
ordinal_cols = ["AppVersion", "SignatureVersion", "OSBuildLab", "MDC2FormFactor", "ChassisType",
"NumericOSVersion", "OSBranch", "OSEdition", "OSSkuFriendlyName", "FlightRing",
"OSVersion", "Processor", "OsPlatformSubRelease", "SKUEditionName", "PrimaryDiskType",
"PowerPlatformRole", "OSArchitecture", "OSInstallType", "AutoUpdateOptionsName",
"OSGenuineState", "LicenseActivationChannel", "EngineVersion"]
The Preprocessing Pipeline
Now I built a proper ColumnTransformer pipeline, so I could process everything in one go like a boss. This setup handled:
- Imputation for missing values
- Binary Encoding for binary features
- Ordinal Encoding for multi-class categorical features
- Passthrough for anything already numeric
column_transformer = ColumnTransformer(
transformers=[
('most_frequent', SimpleImputer(strategy='most_frequent'), most_frequent_cols),
('binary', Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('binary', BinaryEncoder())
]), binary_cols),
('ordinal', Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('ordinal', OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1))
]), ordinal_cols),
], remainder='passthrough')
column_transformer.fit(X_train_dropped)
X_train_transformed = column_transformer.transform(X_train_dropped)
test_transformed = column_transformer.transform(test_dropped)
Scaling Features
For scale-sensitive models like KNN, SVM, MLP, I scaled everything between 0 and 1 using MinMaxScaler. Not every model needs it, but for the ones that do? It’s the difference between “Haan decent” and “Okay, that’s actually smart...”
min_max_scaler = MinMaxScaler()
X_train_scaled = min_max_scaler.fit_transform(X_train_transformed)
test_scaled = min_max_scaler.transform(test_transformed)
Model Building
With the data now cleaned, encoded, and scaled, it was time to try out different classification models — starting from the absolute basics, and working up to the monsters like LightGBM and XGBoost.
Train-Test Split
Before training anything, I split the data to simulate a real-world scenario — training on 90% and validating on 10%. Why two splits? One for models that needed scaled data (like KNN, SVM), and one for models that didn’t (like tree-based models).
# For Scale-Sensitive Models
X_train_scaled, X_val_scaled, y_train_scaled, y_val_scaled = train_test_split(X_train_scaled, y_train, test_size=0.1, random_state=69)
# For Scale-Insensitive Models
X_train_transformed, X_val_transformed, y_train_transformed, y_val_transformed = train_test_split(X_train_transformed, y_train, test_size=0.1, random_state=69)
Baseline Model
I started with a DummyClassifier. Why? Because I needed to know the bare minimum a model had to beat to be taken seriously.
Dummy model gave an accuracy of 0.511, which is expected for a binary classification problem.
dummy_classifier = DummyClassifier(random_state=69)
dummy_classifier.fit(X_train_scaled, y_train_scaled)
y_pred_dummy = dummy_classifier.predict(X_val_scaled)
dummy_accuracy = accuracy_score(y_val_scaled, y_pred_dummy)
print(f"Dummy Accuracy: {dummy_accuracy:.4f}")
K-Nearest Neighbor
Next, I brought in KNN — the classic “ask your neighbors” approach. Simple, intuitive, and surprisingly decent. KNN gave an accuracy of 0.5598, slightly better than the baseline (0.5110) and crosses the 0.55 cut-off. This indicates the model is learning patterns but is still weak.
knn_classifier = KNeighborsClassifier()
knn_classifier.fit(X_train_scaled, y_train_scaled)
y_pred_knn = knn_classifier.predict(X_val_scaled)
knn_accuracy = accuracy_score(y_val_scaled, y_pred_knn)
print(f"KNN Accuracy: {knn_accuracy:.4f}")
Stochastic Gradient Descent
SGD came in next — fast, efficient, but sometimes reckless. SGD gave an accuracy of 0.5946, showing improvement over KNN. The model is learning better patterns but still has room for optimization.
sgd_classifier = SGDClassifier(random_state=69)
sgd_classifier.fit(X_train_scaled, y_train_scaled)
y_pred_sgd = sgd_classifier.predict(X_val_scaled)
sgd_accuracy = accuracy_score(y_val_scaled, y_pred_sgd)
print(f"SGD Accuracy: {sgd_accuracy:.4f}")
Logistic Regression
Everyone’s first love in ML — Logistic Regression. Easy to explain, and surprisingly strong for linear problems. Logistic Regression achieved an accuracy of 0.5989, Slightly better than SGD, but not revolutionary.
logistic_regression = LogisticRegression(max_iter=500)
logistic_regression.fit(X_train_scaled, y_train_scaled)
y_pred_logistic = logistic_regression.predict(X_val_scaled)
logistic_accuracy = accuracy_score(y_val_scaled, y_pred_logistic)
print(f"Logistic Regression Accuracy: {logistic_accuracy:.4f}")
Support Vector Machine
SVM doesn’t mess around. It draws a boundary and dares your data to cross it. But... it's also slow as hell on large datasets. SVM hit an accuracy of 0.6013
Finally crossing the 0.60 mark! Not bad, but training this felt like waiting for Indian trains during monsoon. While it crossed 0.60 but the gain is minimal—perhaps hyperparameter tuning can unlock more potential!
svm_classifier = SVC()
svm_classifier.fit(X_train_scaled, y_train_scaled)
y_pred_svm = svm_classifier.predict(X_val_scaled)
svm_accuracy = accuracy_score(y_val_scaled, y_pred_svm)
print(f"SVM Accuracy: {svm_accuracy:.4f}")
Multi-Layer Perceptron
Time to let deep learning dip its toes in. MLP is the classic go-to for dense, non-linear stuff. MLP achieved an accuracy of 0.6115, It outperforms SVM, showing deeper learning is paying off.
mlp_classifier = MLPClassifier(max_iter=500, random_state=1437)
mlp_classifier.fit(X_train_scaled, y_train_scaled)
y_pred_mlp = mlp_classifier.predict(X_val_scaled)
mlp_accuracy = accuracy_score(y_val_scaled, y_pred_mlp)
print(f"MLP Accuracy: {mlp_accuracy:.4f}")
Random Forest
Tree-based, fast, and powerful. It doesn’t care about scaling, outliers, or your excuses. Random Forest scored an accuracy of 0.6177, slightly surpassing MLP.
Being a bagging-based model (Multiple Decision Trees trained in parallel on different subsets), it reduces variance. While it's performing well, the improvement is marginal—further tuning or feature selection might help boost it further!
rfc_classifier = RandomForestClassifier(random_state=69)
rfc_classifier.fit(X_train_transformed, y_train_transformed)
y_pred_rfc = rfc_classifier.predict(X_val_transformed)
rfc_accuracy = accuracy_score(y_val_transformed, y_pred_rfc)
print(f"Random Forest Accuracy: {rfc_accuracy:.4f}")
Extreme Gradient Boosting
As a boosting-based model, it sequentially improves weak learners, reducing both bias and variance for stronger predictions. XGBoost achieved an accuracy of 0.6251, marking the highest performance so far! It outperforms Random Forest, showing the power of boosting in capturing complex patterns.
xgb_classifier = XGBClassifier()
xgb_classifier.fit(X_train_transformed, y_train_transformed)
y_pred_xgb = xgb_classifier.predict(X_val_transformed)
xgb_accuracy = accuracy_score(y_val_transformed, y_pred_xgb)
print(f"XGBoost Accuracy: {xgb_accuracy:.4f}")
LightGBM - The Silent Killer
Faster than XGBoost. Lighter. Meaner. Cleaner. I saved this one for last. LGBM achieved an accuracy of 0.6292, taking the lead over XGBoost!
Its efficiency and speed make it a strong contender for the best model. With some hyperparameter tuning, we might unlock even more potential!
lgbm_classifier = LGBMClassifier(verbose=-1)
lgbm_classifier.fit(X_train_transformed, y_train_transformed)
y_pred_lgbm = lgbm_classifier.predict(X_val_transformed)
lgbm_accuracy = accuracy_score(y_val_transformed, y_pred_lgbm)
print(f"LGBM Accuracy: {lgbm_accuracy:.4f}")
Model Comparison
And the winner for now? LightGBM. It delivered speed, accuracy, and didn’t eat all my RAM.

models = ["Dummy\nClassifier", "K-Nearest\nNeighbors", "Stochastic Gradient\nDescent",
"Logistic\nRegression", "Support Vector\nMachine", "Multi-Layer\nPerceptron",
"Random\nForest", "Extreme Gradient\nBoosting", "Light Gradient\nBoosting Machine"]
accuracies = [dummy_accuracy, knn_accuracy, sgd_accuracy, logistic_accuracy, svm_accuracy,
mlp_accuracy, rfc_accuracy, xgb_accuracy, lgbm_accuracy]
plt.figure(figsize=(20, 5))
sns.set_style("whitegrid")
colors = sns.color_palette("coolwarm", len(models))
# Barplot
ax = sns.barplot(x=np.array(models), y=np.array(accuracies), palette=colors, edgecolor="black", width=0.6)
# Label
for bar, accuracy in zip(ax.patches, accuracies):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.002, f"{accuracy:.3f}",
ha='center', va='bottom', fontsize=12, fontweight="bold", color="black")
plt.xticks(fontsize=12)
plt.yticks(fontsize=12)
plt.ylim(0.50, 0.65)
plt.xlabel("")
plt.ylabel("Accuracy", fontsize=14, fontweight="bold", labelpad=10)
plt.title("Model Accuracy Comparison", fontsize=16, fontweight="bold", pad=15)
sns.despine()
plt.show()
Model Optimization
Now here’s the thing — I’d been training and validating on 90% of the data. But since I was about to tune this model for submission...
Now I trained the model on 100% of the data. Yes, it’ll overfit on this set. But who cares? I’m not evaluating anymore — I’m optimizing for the final submission.
This stage is all about pushing the accuracy on the test set using the best-tuned parameters. Validation drama ends here.
X_train_transformed = column_transformer.transform(X_train_dropped)
Tuning LightGBM
I used RandomizedSearchCV to find the best combo of hyperparameters. Why Randomized and not GridSearch? Because I value my time, RAM, and sanity. I tuned three time and got accuracies of 0.7261, 0.7493 and 0.7284 respectively.
Note: These are definitely overfitting slightly now since we’re using 100% of the data. But again, who cares? Now I just want to push accuracy on the private data, so obviously I’m using the full dataset.
param_dist = {
'num_leaves': np.arange(100, 300, 7),
'max_depth': np.arange(5, 15, 1),
'learning_rate': np.linspace(0.01, 0.1, 100),
'n_estimators': np.arange(100, 300),
'min_child_samples': np.arange(5, 30),
'min_child_weight': np.logspace(-2, 1, 100),
'subsample': np.linspace(0.7, 1.0, 100),
'colsample_bytree': np.linspace(0.7, 1.0, 100),
'reg_alpha': np.logspace(-3, 0, 100),
'reg_lambda': np.logspace(-3, 0, 100)
}
lgbm = LGBMClassifier(verbose=-1)
random_search = RandomizedSearchCV(
estimator=lgbm,
param_distributions=param_dist,
n_iter=50,
scoring='accuracy',
cv=5,
verbose=1,
n_jobs=-1,
random_state=69
)
random_search.fit(X_train_transformed, y_train_transformed)
best_lgbm = random_search.best_estimator_
best_lgbm
# 3 LGBM Models
lgbm1 = LGBMClassifier(num_leaves=146, max_depth=11, learning_rate=0.04059333955483345, n_estimators=215, min_child_samples=12, min_child_weight=5.348307316066151, subsample=0.8015824978715238, colsample_bytree=0.7528665653163558, reg_alpha=0.01794072813847298, reg_lambda=0.00446283946806131, verbose=-1)
lgbm2 = LGBMClassifier(num_leaves=241, max_depth=11, learning_rate=0.04267548857660626, n_estimators=176, min_child_samples=17, min_child_weight=0.34453075904087466, subsample=0.9420126904183629, colsample_bytree=0.8969001870626069, reg_alpha=0.2534079006185169, reg_lambda=0.22093082929171354, verbose=-1)
lgbm3 = LGBMClassifier(num_leaves=227, max_depth=10, learning_rate=0.02643396947526321, n_estimators=247, min_child_samples=16, min_child_weight=0.3399998175512427, subsample=0.9860913792672888, colsample_bytree=0.9193166643298747, reg_alpha=0.007954215605781124, reg_lambda=0.1780820734696766, verbose=-1)
lgbm1.fit(X_train_transformed, y_train)
lgbm2.fit(X_train_transformed, y_train)
lgbm3.fit(X_train_transformed, y_train)
lgbm1_accuracy = accuracy_score(y_val_transformed, lgbm1.predict(X_val_transformed))
lgbm2_accuracy = accuracy_score(y_val_transformed, lgbm2.predict(X_val_transformed))
lgbm3_accuracy = accuracy_score(y_val_transformed, lgbm3.predict(X_val_transformed))
print(f"Tuned LGBM1 Accuracy: {lgbm1_accuracy:.4f}")
print(f"Tuned LGBM2 Accuracy: {lgbm2_accuracy:.4f}")
print(f"Tuned LGBM3 Accuracy: {lgbm3_accuracy:.4f}")
Ensemble Learning with Voting Classifier
Combined above three tuned LightGBM models using a Voting Classifier for better generalization. Achieved an accuracy of 0.7359, slightly lower than the best individual LGBM model (0.7493).
Despite the lower accuracy, ensembling improves robustness and is expected to perform better on unseen data. And boom — rightly so! I submitted this model to check accuracy on the private data, and it came out as 0.6470. That’s how I landed at #1 on the leaderboard.

base_models = [('lgbm1', lgbm1), ('lgbm2', lgbm2), ('lgbm3', lgbm3)]
voting_classifier = VotingClassifier(estimators=base_models, voting='hard')
voting_classifier.fit(X_train_transformed, y_train)
voting_classifier_accuracy = accuracy_score(y_val_transformed, voting_classifier.predict(X_val_transformed))
print(f"Voting Classifier Accuracy: {voting_classifier_accuracy:.4f}")
Tuning 2.0: The Diabolical Tuning
Why settle for less when you can tweak, tune, and keep trying? I kept adding more LGBMs, always chasing that elusive 0.75 accuracy. Sure, it's a rollercoaster with overfitting on the private data scores, but you know what? This is where the fun begins. After all, in India, we don’t stop at Good Enough.

# 5 More LGBM Models
lgbm4 = LGBMClassifier(num_leaves = 181, max_depth = 10, learning_rate = 0.04130232296795349, n_estimators = 228, min_child_samples = 29, min_child_weight = 1.8974552838538845, subsample = 0.7827368388408219, colsample_bytree = 0.7629249518899549, reg_alpha = 0.07533822749656874, reg_lambda = 0.0015430279436605976)
lgbm5 = LGBMClassifier(num_leaves=169, max_depth=10, learning_rate=0.05233350977069071, n_estimators=231, min_child_samples=22, min_child_weight=0.4601062504283119, subsample=0.9390809394397799, colsample_bytree=0.7764703821961773, reg_alpha=0.1598399044304671, reg_lambda=1.6612340944171649)
lgbm6 = LGBMClassifier(num_leaves=269, max_depth=11, learning_rate=0.035393108325162924, n_estimators=229, min_child_samples=23, min_child_weight=0.5340539301261625, subsample=0.9246962331578512, colsample_bytree=0.9088757439239776, reg_alpha=0.2602063481715106, reg_lambda=1.0107331287932972)
lgbm7 = LGBMClassifier(num_leaves=186, max_depth=11, learning_rate=0.04584370337618702, n_estimators=151, min_child_samples=25, min_child_weight=0.1159493172007911, subsample=0.5484014391486206, colsample_bytree=0.7508636391009044, reg_alpha=0.004148047682050917, reg_lambda=0.0072449781860701905)
lgbm8 = LGBMClassifier(num_leaves = 160, max_depth = 13, learning_rate = 0.041619634901885666, n_estimators = 252, min_child_samples = 39, min_child_weight = 0.01163215435201914, subsample = 0.9461943583274226, colsample_bytree = 0.7569519218012518, reg_alpha = 0.003349476296685088, reg_lambda = 0.1693179077289772)
# One XGB Model
xgb = XGBClassifier(n_estimators = 387, max_depth = 9, learning_rate = 0.026577196331014415, subsample = 0.985994351977102, colsample_bytree = 0.8102255771398449, gamma = 1.2078424008928528, reg_alpha = 0.18482880642623628, reg_lambda = 0.03203503173352191, scale_pos_weight = 1.004871302905267)
lgbm4.fit(X_train_transformed, y_train)
lgbm5.fit(X_train_transformed, y_train)
lgbm6.fit(X_train_transformed, y_train)
lgbm7.fit(X_train_transformed, y_train)
lgbm8.fit(X_train_transformed, y_train)
xgb.fit(X_train_transformed, y_train)
lgbm4_accuracy = accuracy_score(y_val_transformed, lgbm4.predict(X_val_transformed))
lgbm5_accuracy = accuracy_score(y_val_transformed, lgbm5.predict(X_val_transformed))
lgbm6_accuracy = accuracy_score(y_val_transformed, lgbm6.predict(X_val_transformed))
lgbm7_accuracy = accuracy_score(y_val_transformed, lgbm7.predict(X_val_transformed))
lgbm8_accuracy = accuracy_score(y_val_transformed, lgbm8.predict(X_val_transformed))
xgb_accuracy = accuracy_score(y_val_transformed, xgb.predict(X_val_transformed))
print(f"Tuned LGBM4 Accuracy: {lgbm4_accuracy:.4f}")
print(f"Tuned LGBM5 Accuracy: {lgbm5_accuracy:.4f}")
print(f"Tuned LGBM6 Accuracy: {lgbm6_accuracy:.4f}")
print(f"Tuned LGBM7 Accuracy: {lgbm7_accuracy:.4f}")
print(f"Tuned LGBM8 Accuracy: {lgbm8_accuracy:.4f}")
print(f"Tuned XGB Accuracy: {xgb_accuracy:.4f}")
Multi Layer Diabolical Voting
Because one layer of chaos wasn’t enough. I stacked voting classifiers on top of other voting classifiers — hoping the ensemble madness captures patterns that single models miss. This is ensembling... But on Steroids.
Voting Classifier : Layer 1
base_models_2 = [('lgbm8', lgbm8), ('lgbm4', lgbm4), ('lgbm5', lgbm5), ('lgbm6', lgbm6), ('lgbm7', lgbm7), ('xgb', xgb)]
voting_classifier_2 = VotingClassifier(estimators=base_models_2, voting='hard')
voting_classifier_2.fit(X_train_transformed, y_train)
voting_classifier_accuracy_2 = accuracy_score(y_val_transformed, voting_classifier_2.predict(X_val_transformed))
print(f"Voting Classifier 2 Accuracy: {voting_classifier_accuracy_2:.4f}")
base_models_3 = [('lgbm8', lgbm8), ('lgbm4', lgbm4), ('lgbm5', lgbm5), ('lgbm2', lgbm2), ('lgbm3', lgbm3)]
voting_classifier_3 = VotingClassifier(estimators=base_models_3, voting='hard')
voting_classifier_3.fit(X_train_transformed, y_train)
voting_classifier_accuracy_3 = accuracy_score(y_val_transformed, voting_classifier_3.predict(X_val_transformed))
print(f"Voting Classifier 3 Accuracy: {voting_classifier_accuracy_3:.4f}")
Voting Classifier : Layer 2
base_models_4 = [('vc2', voting_classifier_2), ('vc3', voting_classifier_3)]
voting_classifier_4 = VotingClassifier(estimators=base_models_4, voting='hard')
voting_classifier_4.fit(X_train_transformed, y_train)
voting_classifier_accuracy_4 = accuracy_score(y_val_transformed, voting_classifier_4.predict(X_val_transformed))
print(f"Voting Classifier 4 Accuracy: {voting_classifier_accuracy_4:.4f}")
Final Submission
Submitted this Monster, and boom — accuracy jumped to 0.6495 (Yes Yes, Let’s call it a respectable 0.65!). And guess what? Still dominating the leaderboard at #1.
predictions = voting_classifier_4.predict(test_transformed)
pred_df = pd.DataFrame({
'id': range(10000),
'target': predictions
})
# Save the predictions DataFrame to a CSV file
pred_df.to_csv('submission.csv', index=False)


Model Evaluation
Malware detection recall is 79%, meaning the model successfully identifies most infected systems but may misclassify some clean ones. False negatives are lower than false positives, indicating the model prioritizes catching malware over mistakenly marking clean systems.
ConfusionMatrixDisplay.from_predictions(y_val_transformed,voting_classifier_4.predict(X_val_transformed))
plt.show()

print(classification_report(y_val_transformed,voting_classifier.predict(X_val_transformed)))
Conclusion
- Signature Version is the most important feature, indicating that malware detection heavily relies on antivirus definitions.
- CityID matters, suggesting some cities are highly targeted, which aligns with real-world attack patterns.
- DiffOS indicates that users who install or update their system before updating antivirus definitions are more likely to be affected by malware, highlighting a key feature engineering direction.
- NumericOSVersion, AntivirusConfigID impact detection, as malware may exploit outdated or misconfigured OS versions.
feature_importance = pd.DataFrame({
'Feature': column_transformer.get_feature_names_out(),
'Importance': lgbm1.feature_importances_
})
feature_importance = feature_importance.sort_values(by='Importance', ascending=False)
feature_importance[:10]
If you made it this far — you're a real one. Whether you're here for the ML, the memes, or just lurking for leaderboard secrets, I hope this ride was worth your time.
This wasn't just about winning — it was about learning, experimenting, overfitting like a maniac, and still shipping something that worked.