DELIGHT Cybersecurity Workbook Series

Malware Detection System

Enter the access code provided by your instructor to open this workbook.

DELIGHT Cybersecurity Workbook Series

Malware Detection System

A complete, step-by-step guide to building a machine-learning malware classifier — from raw PE file features to a trained Random Forest model you can use to predict whether a Windows executable is malicious.

PythonGoogle Colabscikit-learn Random ForestPE AnalysisMinMaxScalerpickleROC / AUC

Table of Contents

  1. Problem explanation — what is malware and why detect it with ML?
  2. Understanding the dataset — PE file features explained
  3. Importing libraries — your cybersecurity toolbox
  4. Loading & first look at the data
  5. ★ Collecting benign PE samples — System32, Program Files & combining with malware dataset
  6. Data analysis & exploration — understanding the feature space
  7. Preprocessing — dropping irrelevant columns & scaling features
  8. Train-test split — evaluating honestly with stratified sampling
  9. Training & comparing five ML algorithms — with ROC curves & SVM
  10. ★ Saving & loading the best model with pickle
  11. ★ Live PE feature analyser — try it right here
  12. ★ Streamlit scanner — drag-and-drop live malware detector
0
Problem explanation — what is malware and why detect it with ML?
Start here

Before writing a single line of Python, it is essential to understand what we are trying to detect, why traditional methods fall short, and how machine learning changes the game. Skipping this context is like learning to drive without understanding what a road is.

What is malware?

Malware (short for "malicious software") is any program intentionally designed to disrupt, damage, or gain unauthorised access to a computer system. Unlike bugs — which are coding mistakes — malware is purposefully harmful. It is written by human attackers with specific goals: stealing data, extorting money, hijacking computing resources, or creating backdoors for future access.

Virus

Attaches itself to legitimate programs. Spreads when the infected program is run or shared. Classic example: ILOVEYOU (2000).

Trojan

Disguises itself as useful software. Once installed, creates a backdoor or exfiltrates data. The victim installs it willingly.

Ransomware

Encrypts the victim's files and demands payment for the decryption key. WannaCry (2017) caused $4 billion in damages globally.

Worm

Self-replicating program that spreads across networks without any user interaction. Consumes bandwidth and disk space.

Spyware

Silently monitors the user — keystrokes, screenshots, webcam footage — and sends the data to an attacker's server.

Rootkit

Hides deep in the operating system. Gives attackers persistent, administrative-level access that is extremely difficult to remove.

Why traditional (signature-based) detection fails

Classic antivirus software uses a signature database — a list of known malware fingerprints (usually MD5 or SHA256 file hashes, or byte sequences). When a file is scanned, its signature is compared against this list. If it matches a known threat: blocked. If not: allowed through.

This approach has a fatal flaw: it can only catch known threats it has already seen. Modern malware authors exploit this in several ways:

  • Polymorphic malware — automatically changes its own code every time it copies itself, generating a unique signature on each infection. One malware family can produce millions of distinct files, each with a different hash.
  • Obfuscation — attackers wrap malware in layers of junk code, encryption, or compression, completely changing the byte signature while the malicious logic remains intact.
  • Zero-day exploits — brand new malware that has never been seen before. No signature exists yet. The antivirus is helpless until the vendor updates its database — often days or weeks later.

How machine learning changes the approach

Instead of asking "have I seen this exact file before?", a machine learning model asks: "does this file behave like malware?" It learns patterns from thousands of features extracted from both malware and legitimate files, then uses those patterns to classify new, never-seen files.

This is called behaviour-based or heuristic detection. Even if the exact file is brand new, if it shares structural characteristics with known malware (suspicious import calls, unusual PE header values, specific section entropy), the model can flag it. This is how modern enterprise security tools like CrowdStrike, Cylance, and Windows Defender's cloud protection work.

Real-world impact: AV-TEST Institute registers over 450,000 new malicious programs every single day. No human team or signature database can keep up. ML-based detectors are not optional in modern cybersecurity — they are the only scalable defence.

Our project objective

We will train a Random Forest classifier on a dataset of Windows executable files (.exe / .dll), each labelled as either malware (1) or benign (0). The model's input is a set of numerical features extracted from the PE (Portable Executable) file format. By the end, you will have a working model that can predict whether a new executable is malicious with over 99% accuracy — and understand exactly why it works.

1
Understanding the dataset — PE file features explained
Domain Knowledge

The dataset we use is dataset_malwares.csv (available on Kaggle). Each row represents one Windows executable file. Each column is a feature extracted from that file's PE (Portable Executable) header — the structured metadata block at the start of every .exe and .dll file on Windows.

Understanding what a PE file is and why its features are useful for malware detection is essential. Without this context, the preprocessing steps later — particularly why we drop certain columns — will seem arbitrary.

What is a PE (Portable Executable) file?

Every Windows program you run — Notepad, Chrome, a game — is stored as a PE file. The PE format is a structured binary container defined by Microsoft. It begins with a PE header that tells Windows: how to load the program into memory, what external libraries it needs, what the code and data sections look like, and various security metadata.

Malware authors cannot fully hide from PE header analysis. Because the OS loader requires valid PE structure to execute the file, certain fields must be present — and those fields carry fingerprints that differ between legitimate software and malware.

The PE section structure

DOS Header + DOS Stub

Legacy header present in all PE files for backwards compatibility. The e_magic field ("MZ") is the first 2 bytes of every valid PE file. Rarely modified by modern malware.

PE File Header (COFF)

Contains the Machine type (x86/x64), number of sections, TimeDateStamp (when the file was compiled), and characteristics flags describing the file type.

Optional Header

Contains the entry point address, image base, code/data sizes, and the data directories (pointers to imports, exports, resources, certificates). Very rich in malware signals.

Section Table + Sections

Describes each segment of the binary: .text (code), .data (variables), .rsrc (resources). Malware often has unusual section names, sizes, or entropy values from packing/encryption.

Key features in our dataset — what each one measures

Feature ColumnWhat it representsMalware signal?
NameOriginal filename of the executable (e.g. setup.exe)DROPPED Filenames are meaningless — malware can be named anything
MachineTarget CPU architecture: 0x014c = x86 (32-bit), 0x8664 = x64 (64-bit)DROPPED Most malware targets both; not a reliable signal
TimeDateStampUnix timestamp of when the linker compiled the fileDROPPED Easily forged; malware routinely sets this to 0 or a random date
SectionMainCharCharacteristics bitmap of the primary sectionDROPPED Encoded flags — less informative than section count or entropy
SizeOfOptionalHeaderByte size of the Optional Header — usually 224 (x86) or 240 (x64)USEFUL Unusual sizes suggest tampering
SizeOfCodeTotal byte size of all code sections combinedUSEFUL Abnormally small code + large data = packed malware
SizeOfInitializedDataSize of data sections that have initial valuesUSEFUL Disproportionate ratios indicate unusual structure
AddressOfEntryPointMemory address where execution beginsUSEFUL Entry point near section boundaries = packed binary
NumberOfSectionsHow many sections the PE file containsUSEFUL Most legitimate files: 4–6 sections; malware may have 1 or 15+
DllCharacteristicsSecurity flags: ASLR support, DEP support, code integrity, etc.USEFUL Malware often disables ASLR/DEP for easier exploitation
SectionMainEntropyEntropy (randomness) of the main code section (0–8)STRONG Entropy > 7.0 strongly suggests packing or encryption
SectionMinEntropyEntropy of the least-random sectionUSEFUL Very low or very high minimum entropy is unusual
SectionMaxEntropyEntropy of the most-random sectionSTRONG Max entropy near 8.0 = almost certainly packed or encrypted
SectionMinRawsizeSmallest raw section size in bytesUSEFUL Tiny sections are unusual in legitimate software
MalwareTarget label: 0 = benign, 1 = malwareTARGET This is what we train the model to predict
Why entropy matters most: Entropy measures randomness on a scale of 0–8 bits. English text has entropy ~4.5. Executable code has entropy ~6.0. But when malware encrypts or compresses itself to evade detection, the resulting data is nearly random — entropy rises to 7.5 or above. This is one of the single strongest indicators of malicious intent in the entire dataset.
2
Importing libraries — your cybersecurity ML toolbox
Setup

Every data science project starts with importing the right tools. Think of these libraries as specialist instruments in a forensics lab — each one has a specific role. Understanding what you are importing and why is more important than memorising the import syntax.

LibraryRole in this projectWhy it's needed
numpyNumerical computingFast array operations used internally by every other library. Handles the heavy matrix arithmetic behind model training.
pandasData manipulationLoads the CSV dataset into a DataFrame — a table-like structure we can slice, filter, and analyse with simple Python commands.
seabornStatistical visualisationCreates polished plots (correlation heatmaps, boxplots, distribution charts) with very little code. Built on top of matplotlib.
matplotlib.pyplotCore plotting engineThe foundational plotting library. Seaborn uses it under the hood. We call it directly for customising chart titles, axes, and size.
sklearn.metricsModel evaluationProvides accuracy_score, classification_report, and confusion_matrix — the tools we use to measure how well each model performs.
sklearn.model_selectionData splittingProvides train_test_split — the function that divides the dataset into a training set (used to learn) and a test set (used to evaluate honestly).
sklearn.preprocessingFeature scalingProvides MinMaxScaler — normalises all feature values to the 0–1 range so that large-valued features don't dominate distance-based algorithms.
sklearn.ensembleEnsemble modelsProvides RandomForestClassifier — the most effective model for PE feature data. Ensemble = combining many decision trees.
sklearn.neighborsDistance-based classifierProvides KNeighborsClassifier — classifies based on similarity to training examples.
sklearn.linear_modelLinear classifierProvides LogisticRegression — a probabilistic baseline model. Despite the name, it is a classification algorithm.
sklearn.treeSingle tree classifierProvides DecisionTreeClassifier — the base learner inside Random Forest. Good for understanding how ensemble methods improve on individual trees.
pickleModel serialisationSaves a trained model to a file (.pkl) so it can be loaded later without retraining. Part of Python's standard library — no install needed.
Python — Cell 1 — Import all libraries
# ── Data handling ──────────────────────────────────────────────────────────
import numpy as np          # numerical arrays and fast math operations
import pandas as pd         # load and manipulate the CSV dataset as a DataFrame

# ── Visualisation ──────────────────────────────────────────────────────────
import seaborn as sns        # statistical charts: heatmap, boxplot, histogram
import matplotlib.pyplot as plt  # core plotting engine used by seaborn

# ── Model evaluation ───────────────────────────────────────────────────────
from sklearn.metrics import (
    accuracy_score,           # proportion of correct predictions: (TP+TN)/Total
    classification_report,   # precision, recall, F1-score, support per class
    confusion_matrix          # 2×2 table of TP, TN, FP, FN
)

# ── Data splitting ─────────────────────────────────────────────────────────
from sklearn.model_selection import train_test_split  # 80/20 split

# ── Preprocessing ──────────────────────────────────────────────────────────
from sklearn.preprocessing import MinMaxScaler  # scale features to [0, 1]

# ── ML Algorithms ──────────────────────────────────────────────────────────
from sklearn.ensemble       import RandomForestClassifier  # best model
from sklearn.neighbors      import KNeighborsClassifier     # distance-based
from sklearn.linear_model   import LogisticRegression       # linear baseline
from sklearn.tree           import DecisionTreeClassifier    # single tree

# ── Model persistence ──────────────────────────────────────────────────────
import pickle  # save and load trained models to/from disk files (.pkl)

print("✅ All libraries imported successfully")
Google Colab tip: All these libraries are pre-installed in Colab — no pip install needed. Just run the import cell and you are ready. If you run this on your own machine you may need pip install scikit-learn pandas seaborn matplotlib.
3
Loading & first look at the data
Data Loading

Before doing any analysis or training, we load the raw CSV file and perform a quick inspection. This step is not optional — it tells us the dataset's shape, data types, and whether there are any immediate problems such as missing values or unexpected columns.

Getting the dataset

The dataset is dataset_malwares.csv, available from Kaggle. Upload it to your Colab session using the Files panel on the left sidebar (the folder icon), or mount your Google Drive. The dataset contains thousands of Windows executables labelled as either malware or benign, with numerical PE header features extracted for each file.

Python — Cell 2 — Load the dataset
# Load the CSV file into a pandas DataFrame
# pd.read_csv() reads the file line by line and creates a table in memory
# where each row = one file, each column = one extracted PE feature
data = pd.read_csv("dataset_malwares.csv")

# ── Quick shape check ──────────────────────────────────────────────────────
# .shape returns a tuple: (number_of_rows, number_of_columns)
# number_of_rows   = how many executable files are in the dataset
# number_of_columns = how many features + the target label column
print(f"Dataset shape: {data.shape}")
print(f"  → {data.shape[0]:,} files (rows)")
print(f"  → {data.shape[1]} columns (features + target)")
Python — Cell 3 — Inspect first rows
# .head() displays the first 5 rows of the DataFrame
# This lets you see what the raw data looks like before any processing
# Look for: column names, data types at a glance, and the Malware target column
data.head()
Python — Cell 4 — Check data types and missing values
# .info() is the most important first inspection call:
# • Lists every column name
# • Shows the data type of each column (int64, float64, object)
# • Shows how many non-null (non-missing) values each column has
# • Reports total memory usage
# If any column shows fewer non-null entries than the total row count,
# those missing values need to be handled before training.
data.info()

print("\n──── Missing value check ────")
print(data.isnull().sum())   # print count of NaN values per column
Python — Cell 5 — Check class balance
# Check how many malware vs benign samples we have
# Class imbalance — e.g. 95% benign, 5% malware — would cause the model
# to "cheat" by always predicting benign and still appear 95% accurate
class_counts = data['Malware'].value_counts()
class_counts.index = class_counts.index.astype(int)
print("Class distribution:")
benign_count  = class_counts.get(0, 0)
malware_count = class_counts.get(1, 0)
total = len(data)
print(f"  Benign  (0): {benign_count:,} files")
print(f"  Malware (1): {malware_count:,} files")
print(f"  Balance: {benign_count/total*100:.1f}% benign / {malware_count/total*100:.1f}% malware")

# Visual representation with a simple bar chart
plt.figure(figsize=(6, 4))
sns.countplot(x='Malware', data=data,
             hue='Malware',
             palette={0: '#00e676', 1: '#ff4d6d'},
             legend=False)
plt.title('Class Distribution: Benign vs Malware', fontweight='bold')
plt.xticks([0, 1], ['Benign (0)', 'Malware (1)'])
plt.ylabel('Number of Files')
plt.show()
Why class balance matters: If the dataset has 90% benign files and 10% malware, a model that simply predicts "benign" for everything would score 90% accuracy without learning anything. We use stratify=targets during the train-test split later to ensure both sets maintain the same benign-to-malware ratio as the original dataset.
Collecting benign PE samples — System32, Program Files & combining with malware dataset
Dataset Balancing
Already have combined_dataset.csv? If your lecturer has provided combined_dataset.csv, you do not need to follow this section at all. Simply: (1) upload combined_dataset.csv to Colab via the Files panel (folder icon on the left sidebar), (2) in Section 3 change pd.read_csv("dataset_malwares.csv") to pd.read_csv("combined_dataset.csv"), then (3) continue from Section 4 as normal. The rest of the workbook works unchanged.
When this section is necessary: Follow the steps below only if combined_dataset.csv is not available, or if you want to understand how the benign samples were collected. The original dataset_malwares.csv has significantly more malware rows than benign ones. A model trained on this imbalance becomes biased toward predicting malware — it learns "when in doubt, say malware" rather than genuinely distinguishing the two classes. The fix is to collect real .exe and .dll files from your own Windows machine, extract the same PE header features, and merge them in before training.

Why collecting real files is better than synthetic oversampling

An alternative approach called SMOTE generates fake benign samples by interpolating between existing ones — drawing a straight line between two real benign files and placing a new point along it. For PE file analysis this creates feature combinations that do not correspond to any executable that has ever actually existed. The model learns to recognise synthetic interpolations, not real files.

Real benign PE files from your Windows machine — notepad.exe, browser DLLs, runtime libraries — are digitally signed, produced by real compilers, and reflect the actual variety of legitimate software in the wild. When the scanner later encounters a legitimate file on a real machine, it has genuinely seen files like it before. This is why local collection is the more reliable approach for a scanner you intend to deploy.

The four source directories on any Windows machine

C:\Windows\System32

Core Windows executables and DLLs — notepad.exe, cmd.exe, calc.exe, and hundreds of system DLLs. Gold-standard known-good files.

C:\Windows\SysWOW64

32-bit versions of system DLLs on 64-bit Windows. Adds x86 vs x64 diversity and different code layout patterns — useful for a robust model.

C:\Program Files

Third-party 64-bit apps — browsers, editors, Office, etc. Richer variety of section sizes, import tables, and DllCharacteristics than system files alone.

C:\Program Files (x86)

32-bit third-party apps. Covers older compiler outputs and legacy PE patterns your model would otherwise never see.

Safety note: We are only reading the PE headers of files already on your machine — not executing them, not downloading anything, and not modifying any files. The pefile library parses the binary header structure without running anything.

Phase 1 — Run the collection script on your Windows PC

Everything in Phase 1 happens on your own Windows machine, not inside Colab. Colab runs on a Linux VM in the cloud and cannot see your C:\ drive — that is why this must be done locally first.

1
Check whether Python is already installed. Press Win + R, type cmd, press Enter. A terminal window opens. Type the command below and press Enter.
Windows Terminal — check Python version
python --version

If you see something like Python 3.11.4 you are ready — skip to Step 2. If you get an error, install Python first:

Windows Terminal — install Python (only if not already installed)
winget install Python.Python.3
Shell vs Terminal: If after running python --version your terminal shows >>> (three angle brackets), you accidentally opened the Python interactive shell. Type exit() and press Enter to leave it. All commands in this guide belong at the C:\Users\YourName> terminal prompt, not the >>> shell.
2
Install the required libraries. In the same terminal window, run:
Windows Terminal — install libraries
pip install pefile tqdm pandas

You will see download progress for each library. When it finishes with Successfully installed you are ready. If pip is not found, try pip3 install pefile tqdm pandas instead.

3
Create a project folder and place both files in it. Create a new folder anywhere on your PC — for example C:\Users\YourName\malware_project\. Place two files in this folder:
  • dataset_malwares.csv — the original Kaggle dataset
  • collect_benign.py — the collection script (Step 4 below)
4
Create collect_benign.py. Open Notepad (or any text editor), paste the entire script below, and save it as collect_benign.py in the project folder. This is the complete, ready-to-run script — nothing else needs to be added.
collect_benign.py — paste this into a .py file and run it locally
# ============================================================
#  collect_benign.py  —  run on your Windows PC, NOT in Colab
#  Scans System32, SysWOW64, Program Files for .exe/.dll files,
#  extracts PE features, combines with dataset_malwares.csv,
#  and saves combined_dataset.csv ready to upload to Colab.
# ============================================================

import os, glob, math, random
import pandas as pd
import pefile
from tqdm import tqdm

# ── Configuration ─────────────────────────────────────────────────────────
BENIGN_SOURCES = [
    r"C:\Windows\System32",
    r"C:\Windows\SysWOW64",
    r"C:\Program Files",
    r"C:\Program Files (x86)",
]
MAX_PER_DIR = 2000                        # raise for a larger dataset
MALWARE_CSV = "dataset_malwares.csv"       # must be in the same folder as this script
OUTPUT_CSV  = "combined_dataset.csv"       # this is what you upload to Colab

# ── Shannon entropy helper ─────────────────────────────────────────────────
def safe_entropy(data):
    if not data: return 0.0
    freq = [0] * 256
    for b in data: freq[b] += 1
    n = len(data)
    return -sum(c/n * math.log2(c/n) for c in freq if c)

# ── PE feature extractor ───────────────────────────────────────────────────
def extract_pe_features(filepath):
    # Returns a feature dict matching dataset_malwares.csv columns,
    # or None if the file is not a valid PE / cannot be read.
    try:
        pe = pefile.PE(filepath, fast_load=False)
    except:
        return None
    try:
        secs = pe.sections if pe.sections else []
        ents, raw_s, virt_s, phys, virts, ptrs, chars = [], [], [], [], [], [], []
        total_len = 0
        for s in secs:
            ents.append(safe_entropy(s.get_data()))
            total_len += s.SizeOfRawData
            raw_s.append(s.SizeOfRawData);  virt_s.append(s.Misc_VirtualSize)
            phys.append(s.SizeOfRawData);   virts.append(s.VirtualAddress)
            ptrs.append(s.PointerToRawData); chars.append(s.Characteristics)

        opt, fh = pe.OPTIONAL_HEADER, pe.FILE_HEADER
        dd = opt.DATA_DIRECTORY if hasattr(opt, 'DATA_DIRECTORY') else []

        SUSP_API = {'VirtualAlloc', 'VirtualProtect', 'WriteProcessMemory',
                    'CreateRemoteThread', 'OpenProcess', 'SetWindowsHookEx',
                    'RegSetValueEx', 'CreateMutex', 'InternetOpen', 'URLDownloadToFile'}
        NORM_SEC = {b'.text', b'.data', b'.rdata', b'.rsrc',
                    b'.reloc', b'.bss', b'.idata', b'.edata', b'.pdata'}

        susp_imp = dir_imp = dir_imp_sz = dir_exp = 0
        if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
            dir_imp = len(pe.DIRECTORY_ENTRY_IMPORT)
            for e in pe.DIRECTORY_ENTRY_IMPORT:
                dir_imp_sz += len(e.imports)
                for i in e.imports:
                    if i.name and i.name.decode(errors='ignore') in SUSP_API:
                        susp_imp += 1
        if hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
            dir_exp = len(pe.DIRECTORY_ENTRY_EXPORT.symbols)
        susp_sec = sum(1 for s in secs if s.Name.rstrip(b'') not in NORM_SEC)

        features = {
            'Name': os.path.basename(filepath),
            'e_magic': pe.DOS_HEADER.e_magic,   'e_cblp': pe.DOS_HEADER.e_cblp,
            'e_cp': pe.DOS_HEADER.e_cp,           'e_crlc': pe.DOS_HEADER.e_crlc,
            'e_cparhdr': pe.DOS_HEADER.e_cparhdr, 'e_minalloc': pe.DOS_HEADER.e_minalloc,
            'e_maxalloc': pe.DOS_HEADER.e_maxalloc,'e_ss': pe.DOS_HEADER.e_ss,
            'e_sp': pe.DOS_HEADER.e_sp,           'e_csum': pe.DOS_HEADER.e_csum,
            'e_ip': pe.DOS_HEADER.e_ip,           'e_cs': pe.DOS_HEADER.e_cs,
            'e_lfarlc': pe.DOS_HEADER.e_lfarlc,  'e_ovno': pe.DOS_HEADER.e_ovno,
            'e_oemid': pe.DOS_HEADER.e_oemid,    'e_oeminfo': pe.DOS_HEADER.e_oeminfo,
            'e_lfanew': pe.DOS_HEADER.e_lfanew,
            'Machine': fh.Machine,                'NumberOfSections': fh.NumberOfSections,
            'TimeDateStamp': fh.TimeDateStamp,    'PointerToSymbolTable': fh.PointerToSymbolTable,
            'NumberOfSymbols': fh.NumberOfSymbols,'SizeOfOptionalHeader': fh.SizeOfOptionalHeader,
            'Characteristics': fh.Characteristics,'Magic': opt.Magic,
            'MajorLinkerVersion': opt.MajorLinkerVersion,
            'MinorLinkerVersion': opt.MinorLinkerVersion,
            'SizeOfCode': opt.SizeOfCode,
            'SizeOfInitializedData': opt.SizeOfInitializedData,
            'SizeOfUninitializedData': opt.SizeOfUninitializedData,
            'AddressOfEntryPoint': opt.AddressOfEntryPoint,
            'BaseOfCode': opt.BaseOfCode,        'ImageBase': opt.ImageBase,
            'SectionAlignment': opt.SectionAlignment,
            'FileAlignment': opt.FileAlignment,
            'MajorOperatingSystemVersion': opt.MajorOperatingSystemVersion,
            'MinorOperatingSystemVersion': opt.MinorOperatingSystemVersion,
            'MajorImageVersion': opt.MajorImageVersion,
            'MinorImageVersion': opt.MinorImageVersion,
            'MajorSubsystemVersion': opt.MajorSubsystemVersion,
            'MinorSubsystemVersion': opt.MinorSubsystemVersion,
            'SizeOfImage': opt.SizeOfImage,      'SizeOfHeaders': opt.SizeOfHeaders,
            'CheckSum': opt.CheckSum,            'Subsystem': opt.Subsystem,
            'DllCharacteristics': opt.DllCharacteristics,
            'SizeOfStackReserve': opt.SizeOfStackReserve,
            'SizeOfStackCommit': opt.SizeOfStackCommit,
            'SizeOfHeapReserve': opt.SizeOfHeapReserve,
            'SizeOfHeapCommit': opt.SizeOfHeapCommit,
            'LoaderFlags': opt.LoaderFlags,
            'NumberOfRvaAndSizes': opt.NumberOfRvaAndSizes,
            'Malware': 0,   # 0 = benign — all files here are known-good
            'SuspiciousImportFunctions': susp_imp,
            'SuspiciousNameSection': susp_sec,
            'SectionsLength': total_len,
            'SectionMinEntropy':     min(ents)  if ents  else 0,
            'SectionMaxEntropy':     max(ents)  if ents  else 0,
            'SectionMinRawsize':     min(raw_s) if raw_s else 0,
            'SectionMaxRawsize':     max(raw_s) if raw_s else 0,
            'SectionMinVirtualsize': min(virt_s)if virt_selse 0,
            'SectionMaxVirtualsize': max(virt_s)if virt_selse 0,
            'SectionMaxPhysical':    max(phys)  if phys  else 0,
            'SectionMinPhysical':    min(phys)  if phys  else 0,
            'SectionMaxVirtual':     max(virts) if virts else 0,
            'SectionMinVirtual':     min(virts) if virts else 0,
            'SectionMaxPointerData': max(ptrs)  if ptrs  else 0,
            'SectionMinPointerData': min(ptrs)  if ptrs  else 0,
            'SectionMaxChar':        max(chars) if chars else 0,
            'SectionMainChar':       secs[0].Characteristics if secs else 0,
            'DirectoryEntryImport':   dir_imp,
            'DirectoryEntryImportSize': dir_imp_sz,
            'DirectoryEntryExport':   dir_exp,
            'ImageDirectoryEntryExport':    dd[0].Size if len(dd) > 0 else 0,
            'ImageDirectoryEntryImport':    dd[1].Size if len(dd) > 1 else 0,
            'ImageDirectoryEntryResource':  dd[2].Size if len(dd) > 2 else 0,
            'ImageDirectoryEntryException': dd[3].Size if len(dd) > 3 else 0,
            'ImageDirectoryEntrySecurity':  dd[4].Size if len(dd) > 4 else 0,
        }
        pe.close()
        return features
    except:
        try: pe.close()
        except: pass
        return None

# ── Directory scanner ──────────────────────────────────────────────────────
def collect_benign_pe_files(source_dirs, max_per_dir):
    all_records = []
    for source_dir in source_dirs:
        if not os.path.isdir(source_dir):
            print(f"⚠️  Skipping (not found): {source_dir}"); continue
        pe_files = []
        for ext in ("*.exe", "*.dll"):
            pe_files.extend(
                glob.glob(os.path.join(source_dir, "**", ext), recursive=True)
            )
        random.shuffle(pe_files)
        pe_files = pe_files[:max_per_dir]
        print(f"
📂 Scanning: {source_dir}  ({len(pe_files)} files)")
        ok = skipped = 0
        for path in tqdm(pe_files, unit="file"):
            rec = extract_pe_features(path)
            if rec: all_records.append(rec); ok += 1
            else: skipped += 1
        print(f"   ✅ Parsed: {ok}  |  ⚠️  Skipped: {skipped}")
    print(f"
✅ Total benign records collected: {len(all_records):,}")
    return all_records

# ── Combine with original malware CSV ─────────────────────────────────────
def combine_with_malware(benign_records, malware_csv_path):
    print(f"
📄 Loading: {malware_csv_path}")
    malware_df  = pd.read_csv(malware_csv_path)
    benign_df   = pd.DataFrame(benign_records)
    cols        = malware_df.columns.tolist()
    # Align columns: fill any missing columns with 0, drop any extras
    combined    = pd.concat(
        [malware_df, benign_df.reindex(columns=cols, fill_value=0)],
        ignore_index=True
    )
    before      = len(combined)
    combined.drop_duplicates(
        subset=[c for c in cols if c != 'Name'], keep='first', inplace=True
    )
    combined = combined.sample(frac=1, random_state=42).reset_index(drop=True)
    total = len(combined)
    b = (combined['Malware'] == 0).sum()
    m = (combined['Malware'] == 1).sum()
    print(f"
{'='*45}")
    print(f"  Total rows     : {total:,}")
    print(f"  Benign  (0)    : {b:,}  ({b/total*100:.1f}%)")
    print(f"  Malware (1)    : {m:,}  ({m/total*100:.1f}%)")
    print(f"  Dupes removed  : {before - total}")
    print(f"{'='*45}")
    return combined

# ── Main ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    if not os.path.isfile(MALWARE_CSV):
        print(f"❌ '{MALWARE_CSV}' not found.")
        print(f"   Place dataset_malwares.csv in the same folder as this script.")
        exit(1)
    print("="*45 + "
  Benign PE Feature Collector
" + "="*45)
    records  = collect_benign_pe_files(BENIGN_SOURCES, MAX_PER_DIR)
    if not records:
        print("❌ No records collected. Check directory paths."); exit(1)
    combined = combine_with_malware(records, MALWARE_CSV)
    combined.to_csv(OUTPUT_CSV, index=False)
    print(f"
✅ Saved → {OUTPUT_CSV}")
    print("   Upload this file to Colab and update pd.read_csv() in Section 3.")
    print("
Done! 🎉")
5
Open a terminal inside your project folder. Open File Explorer, navigate to the folder containing both files, click the address bar at the top, type cmd, and press Enter. This opens a terminal already pointing at that folder.
6
Run the script. In the terminal, run the command below and press Enter. You will see a progress bar for each directory. The scan takes roughly 5–15 minutes.
Windows Terminal — run the collection script
python collect_benign.py
What you will see while it runs: A progress bar for each directory (System32, SysWOW64, Program Files, Program Files x86), a count of parsed vs skipped files per directory, and a final summary table showing total rows and the new Benign/Malware ratio. When it finishes with Done! 🎉, the file combined_dataset.csv has been saved in your project folder.
A well-balanced result is anything between 40/60 and 60/40 (benign/malware). This range is considered balanced and requires no further correction. The model will now have genuine exposure to what legitimate PE files look like.

Phase 2 — Upload to Colab and connect to the workbook

Everything from this point happens inside Google Colab. You now have combined_dataset.csv on your PC — here is how to bring it in.

7
Upload the file to Colab. In your open Colab notebook, look at the left sidebar. Click the folder icon (📁) to open the Files panel. Click the Upload button (the page-with-arrow icon at the top of the panel). Navigate to your project folder and select combined_dataset.csv. Wait for the upload — you will see it appear in the file list.
8
Update one line in Section 3. Find the cell in Section 3 that loads the dataset and change it as shown below:
Section 3 — the only line you need to change
# Change FROM this:
data = pd.read_csv("dataset_malwares.csv")

# Change TO this:
data = pd.read_csv("combined_dataset.csv")
9
Run all cells from Section 3 downward in order. Go to Runtime → Run all, or run each cell manually starting from Section 3. Sections 1 and 2 do not need to be rerun unless your session disconnected. Every section from 4 onward — EDA, preprocessing, train/test split, model training, evaluation — all use the data variable that Section 3 creates, so the balanced dataset flows through the entire pipeline automatically with no other changes needed.
Colab session resets: Colab does not permanently store uploaded files. If your session disconnects or restarts, re-upload combined_dataset.csv via the Files panel. To avoid this, save the file to Google Drive and mount your Drive: from google.colab import drive; drive.mount('/content/drive'), then update the path to "/content/drive/My Drive/combined_dataset.csv".
Optional — further expansion: For tens of thousands of benign samples, see the EMBER dataset at github.com/elastic/ember — 300,000 pre-labelled benign PE feature vectors in CSV-compatible format, ready to merge with the same approach used in the script above.
4
Data analysis & exploration — understanding the feature space
EDA

Exploratory Data Analysis (EDA) is the process of understanding your data before modelling it. Think of it as studying a crime scene before writing your report. You want to know: which features vary most between malware and benign? Are there outliers? Which features are correlated with each other? Answering these questions builds intuition that makes your model choices more informed and your results more interpretable.

Step 1 — Statistical summary

describe() returns eight summary statistics for every numerical column: count (how many non-null values), mean (average), std (standard deviation — how spread out the values are), and the 0th/25th/50th/75th/100th percentiles. Compare these statistics between malware and benign groups to spot patterns.

Python — Cell 6 — Statistical summary by class
# Overall statistical summary of all numerical features
print("── Full dataset statistics ──")
print(data.describe().round(2))

# Compare means between malware (1) and benign (0) for each feature
# Large differences between the two groups = high predictive value
print("\n── Mean feature values by class ──")
print(data.groupby('Malware').mean(numeric_only=True).round(3).T)  # .T transposes for readability

Step 2 — Entropy distribution analysis (the most revealing plot)

Section entropy is one of the strongest signals in our dataset. Malware commonly packs or encrypts itself to evade signature scanners, which causes its code sections to look nearly random (high entropy). Legitimate software has structured, predictable code — typically lower entropy. Plotting the entropy distribution for both classes side by side makes this separation dramatically visible.

Python — Cell 7 — Entropy distribution by class
# KDE (Kernel Density Estimate) plot: a smooth histogram showing the
# distribution of entropy values for each class overlaid on the same chart
# We expect malware (red) to peak at higher entropy values than benign (green)

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
entropy_cols = ['SectionMaxEntropy', 'SectionMinEntropy', 'SectionsLength']

for ax, col in zip(axes, entropy_cols):
    for label, colour in [(0, '#00e676'), (1, '#ff4d6d')]:
        subset = data[data['Malware'] == label][col].dropna()
        if len(subset) > 1:
            ax.hist(subset, bins=50, alpha=0.55, color=colour,
                   density=True, label='Benign' if label==0 else 'Malware')
    ax.set_title(col, fontweight='bold')
    ax.set_xlabel('Entropy (bits)')
    ax.set_ylabel('Density')
    ax.legend()
    ax.axvline(7.0, color='#ffb300', linestyle='--', linewidth=1,
               label='Entropy = 7.0 threshold')

plt.suptitle('Section Entropy Distribution: Malware vs Benign',
            fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
What you will see: The benign (green) distribution of SectionMainEntropy peaks around 5.0–6.5 — typical for structured executable code. The malware (red) distribution has a much heavier right tail, with many files scoring 7.0–8.0. This visual confirms that entropy is one of the most discriminating features in the dataset. A simple threshold rule (flag anything with entropy > 7.2) would already catch a large fraction of malware.

Step 3 — Feature correlation heatmap

A correlation heatmap shows the pairwise relationship between every feature. Values range from –1 (perfectly inverse) to +1 (perfectly correlated), with 0 meaning no relationship. We use this for two reasons:

  • Finding highly correlated pairs — if two features are 0.95 correlated, they carry near-identical information. Keeping both is redundant and may slow training without improving accuracy.
  • Finding features correlated with the target (Malware column) — these are the features that best separate the classes. Features with near-zero correlation to the target are likely weak predictors.
Python — Cell 8 — Correlation heatmap
# Compute the correlation matrix for all numerical columns
# .corr() computes Pearson correlation coefficient between every column pair

# Drop the non-numeric columns we will remove later anyway
numeric_data = data.drop(['Name', 'Machine', 'TimeDateStamp', 'SectionMainChar'], axis=1)

corr_matrix = numeric_data.corr()

plt.figure(figsize=(14, 11))
sns.heatmap(
    corr_matrix,
    annot=True,           # write the correlation number in each cell
    fmt='.2f',            # format to 2 decimal places
    cmap='RdYlGn',       # red = negative, yellow = near zero, green = positive
    center=0,             # centre the colour scale at 0
    linewidths=0.3,
    linecolor='#1c2436'
)
plt.title('Feature Correlation Matrix — Malware Dataset', fontweight='bold', fontsize=13)
plt.tight_layout()
plt.show()

# Print the features most correlated with the Malware target column
print("\nFeature correlations with Malware target (sorted high to low):")
print(corr_matrix['Malware'].sort_values(ascending=False).round(3))

Step 4 — Boxplots for top discriminating features

A boxplot shows the spread of values for each class. The box represents the middle 50% of values (interquartile range), the line in the middle is the median, and the whiskers extend to the rest of the data. Dots outside the whiskers are outliers. For malware detection, features where the two class boxes barely overlap are the strongest classifiers.

Python — Cell 9 — Boxplots for key features
# Choose the features most likely to differ between classes based on EDA
numeric_data['Malware'] = numeric_data['Malware'].astype(int)
top_features = [
    'SectionMaxEntropy', 'SectionMinEntropy',
    'SizeOfCode',        'NumberOfSections',
    'DllCharacteristics', 'SizeOfInitializedData'
]

fig, axes = plt.subplots(2, 3, figsize=(16, 8))
axes = axes.flatten()  # flatten to 1D for easy iteration

for i, feature in enumerate(top_features):
    sns.boxplot(
        x='Malware', y=feature, data=numeric_data,
        palette={0: '#00e676', 1: '#ff4d6d'},
        hue='Malware', legend=False,
        ax=axes[i], fliersize=2   # make outlier dots smaller for clarity
    )
    axes[i].set_title(feature, fontweight='bold')
    axes[i].set_xlabel('')
    axes[i].set_xticks([0, 1])
    axes[i].set_xticklabels(['Benign', 'Malware'])

plt.suptitle('Feature Distributions by Class: Benign vs Malware',
            fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
5
Preprocessing — dropping irrelevant columns & scaling features
Preprocessing

Raw data is almost never ready to feed directly into a model. This section covers two essential preprocessing steps: removing columns that would mislead or add noise, and normalising the remaining features so they are all on the same scale. Both steps have strong theoretical justifications — they are not housekeeping, they are core to model performance.

Step 1 — Dropping problematic columns

We identified four columns to remove during the dataset exploration in Section 1:

1
Name — The filename of the executable. Filenames are completely arbitrary. Malware can call itself "GoogleUpdate.exe" and legitimate software can have random names. Using this as a feature would cause the model to memorise filenames rather than learn real patterns. Worse: it is a string, not a number, so it cannot be used directly by scikit-learn models without encoding.
2
Machine — Target CPU architecture. Most malware is compiled for both x86 and x64, and modern Windows runs both. This field provides almost no discriminating information between malware and benign executables in practice.
3
TimeDateStamp — Compilation timestamp. This sounds useful — older files might be more suspicious — but malware routinely forges this value to mimic legitimate software dates, or sets it to 0. Using it would teach the model to trust a forged field.
4
SectionMainChar — Section characteristics as a packed bitmap flag integer. While theoretically informative, this field is highly correlated with other, more interpretable features already in the dataset, adding noise without new information.
Python — Cell 10 — Drop low-value columns
# Confirm these columns exist before dropping (defensive check)
cols_to_drop = ['Name', 'Machine', 'TimeDateStamp', 'SectionMainChar']
print("Columns before dropping:", data.columns.tolist())

# axis=1 means "drop columns" (axis=0 would drop rows)
# inplace=True modifies the DataFrame directly without needing to reassign
data.drop(cols_to_drop, axis=1, inplace=True)

print("\nColumns after dropping:", data.columns.tolist())
print(f"\nDataset shape after dropping: {data.shape}")
print("✅ Low-value columns removed")

Step 2 — Feature scaling with MinMaxScaler

After dropping columns, we have a set of numerical features with very different value ranges:

  • NumberOfSections ranges from 1 to about 20 — small numbers
  • SizeOfCode can range from 0 to hundreds of millions — enormous numbers
  • SectionMaxEntropy ranges from 0 to 8 — small floats

This difference in scale causes serious problems for distance-based algorithms like KNN and gradient-based algorithms like Logistic Regression. When computing the "distance" between two files, a feature like SizeOfCode (0–100,000,000) will dominate the distance calculation, making NumberOfSections (1–20) essentially invisible. The model would learn almost exclusively from file size, ignoring all the other features entirely.

MinMaxScaler transforms each feature to the [0, 1] range using the formula: X_scaled = (X - X_min) / (X_max - X_min). After scaling, every feature contributes equally to distance calculations.

Critical rule — fit on training data only: The scaler must be fit_transform() only on the training set. After that, use only transform() on the test set and on any future data. Why? Because fit() computes the min and max from the data it sees. If you let it see test data, information from the test set leaks into your model, making your accuracy estimate dishonestly optimistic. This is called data leakage — one of the most common and serious mistakes in ML projects.
Python — Cell 11 — Separate features and target, then scale
# Separate the feature matrix (X) from the target column (y)
# features = everything EXCEPT the target 'Malware' column
# targets  = only the 'Malware' column (0 = benign, 1 = malware)
features = data.drop("Malware", axis=1)  # shape: (n_samples, n_features)
targets  = data['Malware']               # shape: (n_samples,)

print(f"Feature matrix shape: {features.shape}")
print(f"Target vector shape:  {targets.shape}")
print(f"Feature columns: {features.columns.tolist()}")

# ── Initialise and apply MinMaxScaler ──────────────────────────────────────
# MinMaxScaler maps each feature to [0, 1] using:
#   X_scaled = (X - X_min) / (X_max - X_min)
# This ensures no single feature dominates distance calculations
scaler = MinMaxScaler()

# fit_transform() on the FULL features matrix here:
# After we split into train/test, we refit on training data only (see Section 7)
features_scaled = scaler.fit_transform(features)

print(f"\nOriginal SizeOfCode range: {features['SizeOfCode'].min():.0f} — {features['SizeOfCode'].max():.0f}")
print(f"Scaled  SizeOfCode range: 0.000000 — 1.000000")
print("✅ Features scaled to [0, 1] range")
6
Train-test split — evaluating honestly with stratified sampling
Data Splitting

Before training any model, we must set aside a portion of the data as a held-out test set. The model never sees this data during training. After training is complete, we use it to evaluate how well the model will perform on files it has never encountered before — which is the only honest measure of a model's usefulness in the real world.

Why this is necessary — the exam analogy

Imagine you are a student preparing for an exam. If your teacher lets you study the exact questions and answers that will appear on the exam, you can score perfectly without having genuinely learned the subject. Evaluating a model on its own training data is exactly this scenario. The model would score near-perfect accuracy simply by memorising the training examples — a phenomenon called overfitting.

A held-out test set acts as a "sealed exam paper" — the model has never seen those examples, so its performance on them is an honest reflection of how it will behave in production.

Why we use stratified sampling

With a standard (non-stratified) random split, there is a small chance the test set ends up with a very different malware-to-benign ratio than the training set — especially in datasets with class imbalance. Setting stratify=targets forces sklearn to preserve the exact malware/benign percentage from the full dataset in both the train and test splits. This ensures comparability across experiments.

Python — Cell 12 — Stratified train-test split
# ── Handle class imbalance: upsample benign to minimum 10 samples ──────────
import numpy as np
from sklearn.utils import resample
from sklearn.impute import SimpleImputer

benign_mask  = targets == 0
malware_mask = targets == 1

features_benign_up, targets_benign_up = resample(
    features_scaled[benign_mask], targets[benign_mask],
    replace=True, n_samples=10, random_state=42
)
features_balanced = np.vstack([features_scaled[malware_mask], features_benign_up])
targets_balanced  = np.concatenate([targets[malware_mask], targets_benign_up])

# ── 80 / 20 train-test split ───────────────────────────────────────────────
# test_size=0.2   → 20% of data held back for testing
# stratify=targets → preserves the benign/malware ratio in both splits
# random_state=42 → fixes the random seed for reproducibility
#                   (every student running this code gets identical splits)
X_train, X_test, y_train, y_test = train_test_split(
    features_balanced, targets_balanced,
    test_size=0.2,
    stratify=targets_balanced,     # ← crucial for imbalanced datasets
    random_state=42
)

# ── Impute NaN values with median (KNN cannot handle NaNs) ────────────────
imputer = SimpleImputer(strategy='median')
X_train = imputer.fit_transform(X_train)
X_test  = imputer.transform(X_test)

print(f"Training set:  {X_train.shape[0]:>6,} files  ({X_train.shape[0]/len(targets_balanced)*100:.0f}%)")
print(f"Test set:      {X_test.shape[0]:>6,} files  ({X_test.shape[0]/len(targets_balanced)*100:.0f}%)")

# Verify that both splits maintain the same class ratio
train_malware_pct = y_train.mean() * 100
test_malware_pct  = y_test.mean()  * 100
print(f"\nMalware % in training set: {train_malware_pct:.1f}%")
print(f"Malware % in test set:     {test_malware_pct:.1f}%")
print("✅ Stratified split complete — both sets have the same class ratio")
About random_state=42: The number 42 has no special significance — it is a pop culture reference (The Hitchhiker's Guide to the Galaxy). What matters is that it is a fixed integer. By passing the same random_state to every experiment, every student in the class gets identical train/test splits, making results directly comparable. Change it to any other integer and you will get a different (but equally valid) split.
7
Training & comparing five ML algorithms — ROC curves, SVM & error guide
Model Training

We will train four different algorithms on the same dataset and compare their accuracy. This comparative approach teaches an essential ML skill: algorithm selection is empirical, not dogmatic. You try several approaches, measure their performance objectively, and choose the best one. Let's start by understanding what each algorithm actually does.

Algorithm 1 — K-Nearest Neighbours (KNN)

KNN is the simplest possible learning algorithm — it does not learn any mathematical model at all. Instead, it memorises all training examples. When a new file arrives, KNN finds the K most similar files in the training set (by Euclidean distance across all features), and classifies the new file by majority vote among those K neighbours.

Why it works for malware: If a new file looks very similar to known malware (similar entropy, similar section structure), its K nearest neighbours will mostly be malware, and the vote will correctly classify it as malicious.

Its weakness: With many features and hundreds of thousands of training examples (as in production security tools), computing distances to every training point for each new file becomes extremely slow.

Python — Cell 13 — Train and evaluate KNN
# ── K-Nearest Neighbours ──────────────────────────────────────────────────
# n_neighbors=5: find the 5 most similar training files by Euclidean distance
# and classify by majority vote among those 5 neighbours
knn = KNeighborsClassifier(n_neighbors=5)

print("⏳ Training KNN...")
knn.fit(X_train, y_train)    # stores all training examples in memory
print("✅ KNN trained")

# Predict on the held-out test set
knn_preds = knn.predict(X_test)

knn_acc = accuracy_score(y_test, knn_preds)
print(f"\n📊 KNN Accuracy: {knn_acc:.4f} ({knn_acc*100:.2f}%)")
print("\nDetailed Classification Report:")
print(classification_report(y_test, knn_preds,
                              target_names=['Benign', 'Malware']))

Algorithm 2 — Random Forest

Random Forest is an ensemble method — it builds many individual decision trees (typically 100–500), each trained on a random subset of the data and a random subset of features, then aggregates their predictions by majority vote. This is called bagging (Bootstrap Aggregating).

Why it works so well for malware: Individual decision trees overfit easily — they memorise the exact quirks of training examples. But when you average the predictions of 100 different trees, each one slightly different, the noise cancels out and the signal remains. Random Forest is nearly always the best-performing algorithm on structured/tabular datasets like PE features. It is also inherently interpretable through feature_importances_ — we can ask it exactly which features it found most useful.

Python — Cell 14 — Train and evaluate Random Forest
# ── Random Forest ────────────────────────────────────────────────────────
# n_estimators=100: build 100 decision trees (the "forest")
# Each tree is trained on a bootstrapped sample (random rows with replacement)
# and uses a random subset of features at each split — this is the "random" part
# random_state=42: reproducible results
rfc = RandomForestClassifier(n_estimators=100, random_state=42)

print("⏳ Training Random Forest (100 trees)...")
rfc.fit(X_train, y_train)
print("✅ Random Forest trained")

rfc_preds = rfc.predict(X_test)
rfc_acc   = accuracy_score(y_test, rfc_preds)

print(f"\n📊 Random Forest Accuracy: {rfc_acc:.4f} ({rfc_acc*100:.2f}%)")
print("\nDetailed Classification Report:")
print(classification_report(y_test, rfc_preds,
                              target_names=['Benign', 'Malware']))

# ── Feature importance plot — what did the forest actually use? ───────────
feature_names = list(numeric_data.drop(columns=['Malware']).columns)
importances   = rfc.feature_importances_

plt.figure(figsize=(10, 5))
bars = plt.barh(range(len(importances)),
               sorted(importances, reverse=True),
               color='#ff6d3a')
sorted_idx = np.argsort(importances)[::-1]  # descending order of importance
plt.yticks(range(len(importances)),
           [feature_names[i] for i in sorted_idx])
plt.xlabel('Feature Importance Score')
plt.title('Random Forest Feature Importances', fontweight='bold')
plt.tight_layout()
plt.show()
Expected result: Random Forest should achieve approximately 99%+ accuracy on this dataset. The feature importance plot will typically show SectionMaxEntropy, SectionMinEntropy, and SizeOfCode as the top three features — confirming the EDA findings from Section 4. This alignment between exploration and model behaviour is a healthy sign.

Algorithm 3 — Logistic Regression

Despite the name, Logistic Regression is a classification algorithm, not regression. It learns a linear decision boundary: it finds a set of weights (one per feature) such that a weighted sum of features + a sigmoid function gives the probability of being malware. If the probability exceeds 0.5, the file is classified as malware.

Why train it here: It serves as our linear baseline. If non-linear models like Random Forest dramatically outperform it, we know the malware/benign boundary is non-linear — which tells us about the nature of the problem. Logistic Regression is also extremely fast and interpretable.

Python — Cell 15 — Train and evaluate Logistic Regression
# ── Logistic Regression ───────────────────────────────────────────────────
# max_iter=1000: allow up to 1000 gradient-descent steps to find the
# optimal weights. Default is 100 which may not converge on this dataset.
# random_state=42: reproducible initialisation
lr = LogisticRegression(max_iter=1000, random_state=42)

print("⏳ Training Logistic Regression...")
lr.fit(X_train, y_train)
print("✅ Logistic Regression trained")

lr_preds = lr.predict(X_test)
lr_acc   = accuracy_score(y_test, lr_preds)

print(f"\n📊 Logistic Regression Accuracy: {lr_acc:.4f} ({lr_acc*100:.2f}%)")
print("\nDetailed Classification Report:")
print(classification_report(y_test, lr_preds,
                              target_names=['Benign', 'Malware']))

Algorithm 4 — Decision Tree

A Decision Tree learns a flowchart of yes/no questions about the features. At each branch, it asks: "Is this feature above or below threshold X?" and routes the file left or right accordingly. At the leaf nodes, it assigns a class label. A single, fully-grown tree tends to overfit — it memorises the training data including its noise.

Why include it: The single Decision Tree lets us visualise the Random Forest's building block. The difference in accuracy between Decision Tree and Random Forest directly shows you the power of ensemble averaging. A well-tuned Random Forest can be 3–5% more accurate than the best single tree, simply by averaging out individual tree errors.

Python — Cell 16 — Train and evaluate Decision Tree
# ── Decision Tree ─────────────────────────────────────────────────────────
# random_state=42: reproducible tree structure
# No max_depth set: tree will grow until all leaves are pure or contain
# fewer than min_samples_split samples — can overfit but instructive to compare
dtc = DecisionTreeClassifier(random_state=42)

print("⏳ Training Decision Tree...")
dtc.fit(X_train, y_train)
print("✅ Decision Tree trained")

dtc_preds = dtc.predict(X_test)
dtc_acc   = accuracy_score(y_test, dtc_preds)

print(f"\n📊 Decision Tree Accuracy: {dtc_acc:.4f} ({dtc_acc*100:.2f}%)")
print("\nDetailed Classification Report:")
print(classification_report(y_test, dtc_preds,
                              target_names=['Benign', 'Malware']))

Final comparison — all four models side by side

Python — Cell 17 — Model comparison chart
# Compile results into a summary DataFrame for easy comparison
results = {
    'Algorithm':  ['KNN',     'Random Forest', 'Logistic Regression', 'Decision Tree'],
    'Model':       [knn,      rfc,              lr,                   dtc],
    'Predictions': [knn_preds, rfc_preds,        lr_preds,             dtc_preds]
}

print(f"{'Algorithm':<22} {'Accuracy':>10}")
print("─" * 35)
for name, preds in zip(results['Algorithm'], results['Predictions']):
    acc = accuracy_score(y_test, preds)
    bar = '█' * int(acc * 40)
    print(f"{name:<22} {acc*100:>6.2f}%  {bar}")

# Bar chart comparison
names  = results['Algorithm']
accs   = [accuracy_score(y_test, p) * 100 for p in results['Predictions']]
colours = ['#82aaff', '#ff6d3a', '#c3e88d', '#c792ea']

plt.figure(figsize=(9, 5))
bars = plt.bar(names, accs, color=colours, edgecolor='#1c2436', linewidth=1.2)
for bar, acc in zip(bars, accs):
    plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() - 2,
             f'{acc:.2f}%', ha='center', va='top', fontweight='bold', fontsize=12)
plt.ylim(70, 101)
plt.title('Model Accuracy Comparison — Malware Detection', fontweight='bold')
plt.ylabel('Accuracy (%)')
plt.tight_layout()
plt.show()
KNN
K-Nearest Neighbours
Good, but slow at scale
Random Forest
Best overall
Best model — save this one
Logistic Regression
Linear baseline
Fast baseline, linear boundary
Decision Tree
Single tree
Interpretable but overfits

Understanding the classification report — precision, recall, and F1 for security

Accuracy alone does not tell the full story in security applications. The classification report breaks down performance per class with four metrics. In the context of malware detection, each metric has a concrete security consequence:

MetricFormulaSecurity meaningCost of failure
Precision (Malware)True Malware / All Flagged as MalwareOf all files your detector flagged, how many were genuinely malicious?Low precision = too many false alarms; security team wastes time investigating benign files
Recall (Malware)True Malware / All Real MalwareOf all malware actually present, how much did your detector catch?Low recall = malware slips through. In security this is the worst outcome — a live infection
F1-Score2 × (Precision × Recall) / (Precision + Recall)Harmonic mean — penalises heavily when either precision or recall is lowUse this when you want a single number that balances both error types
SupportCount of actual samples of this class in the test setTells you how statistically meaningful the metric isMetrics from small support values are unreliable — treat with caution
Confusion matrix — visualising all four outcome types: A confusion matrix is a 2×2 table showing True Positives (correctly caught malware), True Negatives (correctly cleared benign files), False Positives (benign files wrongly flagged), and False Negatives (malware that slipped through). In malware detection, a False Negative is an active infection — far more dangerous than a False Positive.
Python — Cell 18 — Confusion matrix for best model
from sklearn.metrics import ConfusionMatrixDisplay

# Plot confusion matrix for the best model (Random Forest)
fig, ax = plt.subplots(figsize=(7, 6))
cm = confusion_matrix(y_test, rfc_preds)

disp = ConfusionMatrixDisplay(confusion_matrix=cm,
                               display_labels=['Benign', 'Malware'])
disp.plot(ax=ax, cmap='Oranges', colorbar=False)

ax.set_title('Random Forest — Confusion Matrix', fontweight='bold', fontsize=13)
print("\nInterpretation:")
print(f"  True Negatives  (Benign caught correctly):   {cm[0,0]:,}")
print(f"  False Positives (Benign wrongly flagged):     {cm[0,1]:,}")
print(f"  False Negatives (Malware that slipped through):{cm[1,0]:,}")
print(f"  True Positives  (Malware caught correctly):   {cm[1,1]:,}")
plt.tight_layout()
plt.show()

Step 5 — ROC curve: evaluating beyond a fixed threshold

The confusion matrix and classification report assume a fixed decision threshold of 0.5: if the model says "probability of malware = 0.6", it flags the file. But what if you want to be more aggressive (lower the threshold to catch more malware, accepting more false positives) or more conservative (raise the threshold to reduce false alarms)?

The ROC (Receiver Operating Characteristic) curve visualises model performance across every possible threshold simultaneously. The x-axis is the False Positive Rate (how many benign files are wrongly flagged at that threshold) and the y-axis is the True Positive Rate (recall — how many real malware files are caught). A perfect classifier's curve hugs the top-left corner. A random guesser produces a diagonal line from (0,0) to (1,1).

The AUC (Area Under the Curve) summarises the entire curve as a single number between 0 and 1. An AUC of 1.0 is a perfect detector; 0.5 is no better than random. For a production malware detector, we want AUC > 0.99.

Python — Cell 19 — ROC curves for all four models
from sklearn.metrics import roc_curve, auc

# predict_proba() returns [P(benign), P(malware)] for each file
# We take column [:, 1] — the malware probability — as the score for ROC
models_for_roc = [
    ('KNN',                 knn),
    ('Random Forest',       rfc),
    ('Logistic Regression', lr),
    ('Decision Tree',       dtc),
]
colours = ['#82aaff', '#ff6d3a', '#c3e88d', '#c792ea']

plt.figure(figsize=(8, 7))

for (name, model), colour in zip(models_for_roc, colours):
    proba        = model.predict_proba(X_test)[:, 1]
    fpr, tpr, _  = roc_curve(y_test, proba)
    roc_auc      = auc(fpr, tpr)
    plt.plot(fpr, tpr, color=colour, lw=2,
             label=f'{name} (AUC = {roc_auc:.4f})')

# Diagonal = random classifier baseline (AUC = 0.50)
plt.plot([0, 1], [0, 1], '--', color='#4a5470',
         label='Random baseline (AUC = 0.5000)')

plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.02])
plt.xlabel('False Positive Rate  (benign files wrongly flagged)')
plt.ylabel('True Positive Rate   (malware files caught)')
plt.title('ROC Curves — All Models', fontweight='bold')
plt.legend(loc='lower right')
plt.tight_layout()
plt.show()
What to look for: Random Forest's ROC curve should hug the top-left corner very tightly — confirming near-perfect class separation. Logistic Regression's curve bows less sharply toward the corner, revealing that the malware/benign boundary is non-linear and a linear model genuinely misses some of it. Decision Tree's AUC can look similar to Random Forest on paper, but its confusion matrix at the 0.5 threshold shows more actual errors — the ROC curve integrates over all thresholds and hides that.

Why SVM is not included in this workbook

A Support Vector Machine (SVM) is a powerful classifier that finds the maximum-margin hyperplane separating classes. However, SVM training complexity is roughly O(n²) in the number of samples — on a balanced dataset of 20,000+ rows it can take 10–30 minutes per run in Colab. More importantly, when tested on this PE dataset, SVM trained on a subset tends to predict almost everything as one class, giving misleadingly high accuracy while completely failing to detect the minority class. The four algorithms included (Random Forest, KNN, Logistic Regression, Decision Tree) provide a thorough, practical comparison without these issues. Random Forest is the clear winner for this task.

If you want to experiment with SVM independently: Use from sklearn.svm import SVC with kernel='rbf' and probability=True. Train on a stratified 5,000-sample subset using StratifiedShuffleSplit to keep training time manageable. Always use y_train[svm_idx] (NumPy indexing) rather than y_train.iloc[svm_idx] to avoid an AttributeError if y_train is a NumPy array. Evaluate on the full test set and compare the ROC AUC — not just accuracy — against the Random Forest result.

Common errors and how to fix them

Error / SymptomLikely causeFix
KeyError: 'Malware'Target column name differs in your CSV versionRun print(data.columns.tolist()) and update all references to match the actual column name (may be 'label' or 'class')
ValueError: could not convert string to floatA text column was not dropped before scalingConfirm you dropped all four text/flag columns before fit_transform()
ConvergenceWarning from Logistic RegressionDefault 100 iterations not enough to convergePass max_iter=1000 to LogisticRegression()
Loaded model gives wrong predictionsScaler was refit on new data — min/max values changedAlways call scaler_loaded.transform() — never fit_transform() — on production data
Training accuracy ~100%, test accuracy ~70%Overfitting — model memorised training noiseConstrain tree depth with max_depth=15 or add more training data
Feature shape mismatch during predictionNew file's feature dict has different or reordered columnsUse features.columns.tolist() from training and build the input array in exactly that order
Saving & loading the best model with pickle
Model Persistence

Training a model every time you want to classify a file would be impractical — a Random Forest with 100 trees can take minutes to train on a large dataset. Model persistence solves this: we serialise the trained model to a .pkl file (pickle format) and load it whenever we need to classify new files, without retraining.

What is pickle?

pickle is Python's built-in object serialisation module. Serialisation means converting a Python object — any object, including a complex trained ML model with hundreds of decision trees — into a stream of bytes that can be written to a file. Deserialisation is the reverse: reading those bytes back and reconstructing the exact same Python object in memory.

When you pickle a RandomForestClassifier, you save everything: all 100 trees, all their branches, all their threshold values, all their leaf class assignments. Loading it restores all of this instantly, with no retraining.

Why save the scaler separately?

The scaler is not part of the model — it is a separate preprocessing step. But it is just as critical. When you use the model in production, new files' raw features must be scaled using exactly the same min/max values that were used during training. If you create a new scaler and refit it on new data, the feature ranges will be slightly different, and the model will receive incorrectly scaled input — leading to degraded or nonsensical predictions.

Always save both the model and the scaler as a pair, and always use both together.

Python — Cell 19 — Save the trained model and scaler
import pickle

# ── Save the trained Random Forest model ──────────────────────────────────
# 'wb' = write binary — pickle produces binary data, not text
model_file = "malware_predictor.pkl"

with open(model_file, "wb") as file:
    pickle.dump(rfc, file)   # serialise the RandomForestClassifier object

print(f"✅ Model saved to '{model_file}'")

# ── Save the MinMaxScaler (must be saved alongside the model) ─────────────
# The scaler stores the min/max of each feature from the training set.
# New data must be scaled with these exact same values — never refit the scaler.
scaler_file = "malware_scaler.pkl"

with open(scaler_file, "wb") as file:
    pickle.dump(scaler, file)

print(f"✅ Scaler saved to '{scaler_file}'")
print("\n📌 Both files will appear in the Colab Files panel (left sidebar).")
print("   Download them to your machine to use this model outside of Colab.")
Python — Cell 20 — Load the saved model and verify it works
# ── Load the saved model from disk ────────────────────────────────────────
# 'rb' = read binary
with open("malware_predictor.pkl", "rb") as file:
    rfc_loaded = pickle.load(file)

with open("malware_scaler.pkl", "rb") as file:
    scaler_loaded = pickle.load(file)

print(f"✅ Loaded model type: {type(rfc_loaded)}")
print(f"   Number of trees in forest: {len(rfc_loaded.estimators_)}")

# Verify the loaded model gives identical predictions to the original
loaded_preds = rfc_loaded.predict(X_test)
match = (loaded_preds == rfc_preds).all()
print(f"\n{'✅' if match else '❌'} Loaded model predictions {'match' if match else 'do NOT match'} original")
print(f"   Accuracy of loaded model: {accuracy_score(y_test, loaded_preds)*100:.2f}%")
Python — Cell 21 — Classify a new file using the saved model
# ── Example: classify a new, hypothetical PE file ─────────────────────────
# In a real deployment, these values would be extracted from an actual
# .exe or .dll file using a PE parsing library like 'pefile' (pip install pefile)
# Here we construct a sample by hand to demonstrate the prediction pipeline

# Feature order must exactly match the order used during training
# (same as the columns in the DataFrame after dropping 4 columns)
feature_order = list(numeric_data.drop(columns=['Malware']).columns)  # e.g. ['SizeOfOptionalHeader', 'SizeOfCode', ...]

# Example 1: suspicious file (high entropy, few sections, small code size)
suspicious_file = {
    col: 0.0 for col in feature_order  # start all at 0
}
# Manually set suspicious values for known malware indicators
suspicious_file['SectionMaxEntropy']  = 7.8   # very high — packed/encrypted
suspicious_file['SectionMinEntropy']  = 7.5   # very high
suspicious_file['NumberOfSections']  = 2     # unusually few sections
suspicious_file['SizeOfCode']        = 4096  # tiny code section = packed

def predict_file(file_features, scaler, model):
    """Scale features and run prediction. Returns 'MALWARE' or 'BENIGN'."""
    raw = np.array([[file_features[col] for col in feature_order]])
    scaled = scaler.transform(raw)    # ← .transform(), NOT fit_transform()
    pred   = model.predict(scaled)[0]
    proba  = model.predict_proba(scaled)[0]
    label  = '🔴 MALWARE' if pred == 1 else '🟢 BENIGN'
    print(f"  Prediction:  {label}")
    print(f"  Probability: Benign={proba[0]:.3f}  Malware={proba[1]:.3f}")
    return pred

print("── Suspicious file (high entropy, tiny code) ──")
predict_file(suspicious_file, scaler_loaded, rfc_loaded)
Taking it further — real PE parsing: To classify actual .exe files, install the pefile library (pip install pefile). It can extract all the features in this dataset from any Windows executable file. The workflow is: load the file with pefile.PE("file.exe"), read the header attributes to build a feature dictionary matching our training columns, run it through predict_file(), and get a verdict. Several open-source tools like VirusTotal do exactly this.
Live PE feature analyser — try it right here in your browser
Interactive Demo

This interactive panel lets you simulate PE feature analysis in your browser — no Python, no Colab required. Enter values for the most discriminating PE features and the scorer will estimate a malware risk score based on the same patterns the Random Forest model learns from. Use it to build intuition about which feature combinations are most suspicious.

This is a browser-side heuristic demo, not the trained Random Forest model. It applies simplified threshold rules derived from dataset analysis to give you an immediate feel for the feature space. Run the Python model in Colab for an authoritative, trained-model classification.

What the analyser checks

  • Section entropy — values above 7.0 indicate packing or encryption, strongly correlated with malware
  • Number of sections — unusually low (<3) or high (>9) section counts are suspicious
  • DLL characteristics — value of 0 means all security mitigations (ASLR, DEP) are disabled — common in malware
  • Code-to-data ratio — packed malware typically has very small code sections relative to its data (the payload is encrypted in a data section)
  • Entry point value — extremely high entry point addresses can indicate the executable was manually unpacked and injected
📜 Interactive PE Feature Analyser — Try It Here

Enter PE header values for a hypothetical executable. All fields have typical legitimate defaults. Modify them to see how suspicion score changes.

Typical PE feature values — benign vs malware reference table

FeatureTypical benign rangeSuspicious (malware-like) valueReasoning
SectionMaxEntropy5.0 – 6.8> 7.2Packing/encryption inflates entropy close to the theoretical maximum of 8.0
SectionMainEntropy4.5 – 6.5> 7.0Legitimate code has predictable structure; encrypted stubs do not
NumberOfSections4 – 8< 3 or > 10Packers create minimal sections; some droppers create many tiny sections
DllCharacteristics32832+ (ASLR+DEP enabled)0 or 32Malware disables ASLR/DEP to make exploitation easier after self-injection
SizeOfCodeProportional to file sizeVery small (<4 KB)Packed malware has a tiny stub that decrypts the real payload at runtime
AddressOfEntryPoint4,096 – 100,000> 1,000,000Entry point deep in memory suggests manual mapping or process injection

Going further — project extension ideas

  • Extract real PE features: Install pip install pefile and write a function that reads an actual .exe file from disk, extracts all the features in our dataset, and runs them through the saved model. Test on files from your own system — Windows system files should always predict benign.
  • Try XGBoost: Replace Random Forest with XGBClassifier from the xgboost package. It often matches or exceeds Random Forest on structured data and trains faster on large datasets. Compare the accuracy and confusion matrices.
  • Cross-validation: Replace the single 80/20 split with 5-fold cross-validation using cross_val_score. This gives a more reliable accuracy estimate because it averages over 5 different train/test partitions.
  • Hyperparameter tuning: Use GridSearchCV to search for the optimal n_estimators and max_depth for Random Forest. Does tuning improve accuracy beyond 99%?
  • SHAP explanations: Install pip install shap and use shap.TreeExplainer to generate a SHAP waterfall plot for individual predictions — showing exactly which features pushed a specific file's prediction towards malware or benign. This is how enterprise security tools explain their decisions to analysts.
  • Build a Streamlit scanner: Wrap your model in a streamlit web app where you drag and drop an .exe file and get an instant verdict with a feature breakdown chart.
Streamlit scanner — drag-and-drop live malware detector
Deployment

In this section you will wrap your trained Random Forest model in a Streamlit web application — a real, browser-based malware scanner where you drag and drop any .exe or .dll file and get an instant ML verdict with a feature breakdown chart. This is the closest thing to a production deployment you can build in a single notebook session.

What is Streamlit?

Streamlit is a Python library that turns a plain .py script into a fully interactive web app with zero HTML, CSS, or JavaScript. Every time the user interacts (uploads a file, moves a slider), Python reruns from top to bottom and the page updates. It is the standard tool for rapid ML demo deployment in the data science and cybersecurity communities.

What the scanner does

When a file is dropped into the uploader, the app: (1) reads the raw bytes, (2) parses the PE structure using pefile to extract all 74 features your model was trained on, (3) scales them with the saved MinMaxScaler, (4) runs them through the saved RandomForestClassifier, and (5) displays the verdict, confidence scores, and a feature importance chart — all in under a second. The file is never executed — only its header bytes are read.

How to run it in Google Colab

Colab does not expose a port to the internet directly, so we use ngrok — a free tunnelling service that creates a temporary public URL pointing at the Streamlit server running inside your Colab VM. You need a free ngrok account to get an authtoken (sign up at dashboard.ngrok.com/signup — no payment required).

Before running: Make sure malware_predictor.pkl and malware_scaler.pkl are in your Colab working directory. They were saved in Section 8. If your runtime has reset, re-run Section 8 first to regenerate them.
Python — Cell 22 — Full Streamlit scanner launcher (run entire cell)
# ══════════════════════════════════════════════════════════════════════════
# MALWARE SCANNER — Complete Colab launcher
# ══════════════════════════════════════════════════════════════════════════

# ── Step 1: Install dependencies ──────────────────────────────────────────
!pip install streamlit pefile plotly pyngrok -q

# ── Step 2: Verify model files exist ──────────────────────────────────────
import os
for f in ["malware_predictor.pkl", "malware_scaler.pkl"]:
    if os.path.exists(f):
        print(f"✅ Found: {f}")
    else:
        raise FileNotFoundError(f"❌ Missing: {f} — upload it first")

# ── Step 3: Write scanner_app.py ──────────────────────────────────────────
app_code = '''
import streamlit as st
import pickle
import numpy as np
import pandas as pd
import pefile
import plotly.graph_objects as go
import math

st.set_page_config(page_title="Malware Scanner", page_icon="🛡️", layout="centered")

@st.cache_resource
def load_model():
    with open("malware_predictor.pkl", "rb") as f:
        model = pickle.load(f)
    with open("malware_scaler.pkl", "rb") as f:
        scaler = pickle.load(f)
    return model, scaler

model, scaler = load_model()

FEATURE_NAMES = [
    "e_magic", "e_cblp", "e_cp", "e_crlc", "e_cparhdr",
    "e_minalloc", "e_maxalloc", "e_ss", "e_sp", "e_csum",
    "e_ip", "e_cs", "e_lfarlc", "e_ovno", "e_oemid",
    "e_oeminfo", "e_lfanew", "NumberOfSections",
    "PointerToSymbolTable", "NumberOfSymbols",
    "SizeOfOptionalHeader", "Characteristics", "Magic",
    "MajorLinkerVersion", "MinorLinkerVersion", "SizeOfCode",
    "SizeOfInitializedData", "SizeOfUninitializedData",
    "AddressOfEntryPoint", "BaseOfCode", "ImageBase",
    "SectionAlignment", "FileAlignment",
    "MajorOperatingSystemVersion", "MinorOperatingSystemVersion",
    "MajorImageVersion", "MinorImageVersion",
    "MajorSubsystemVersion", "MinorSubsystemVersion",
    "SizeOfHeaders", "CheckSum", "SizeOfImage", "Subsystem",
    "DllCharacteristics", "SizeOfStackReserve", "SizeOfStackCommit",
    "SizeOfHeapReserve", "SizeOfHeapCommit", "LoaderFlags",
    "NumberOfRvaAndSizes", "SuspiciousImportFunctions",
    "SuspiciousNameSection", "SectionsLength",
    "SectionMinEntropy", "SectionMaxEntropy",
    "SectionMinRawsize", "SectionMaxRawsize",
    "SectionMinVirtualsize", "SectionMaxVirtualsize",
    "SectionMaxPhysical", "SectionMinPhysical",
    "SectionMaxVirtual", "SectionMinVirtual",
    "SectionMaxPointerData", "SectionMinPointerData",
    "SectionMaxChar", "DirectoryEntryImport",
    "DirectoryEntryImportSize", "DirectoryEntryExport",
    "ImageDirectoryEntryExport", "ImageDirectoryEntryImport",
    "ImageDirectoryEntryResource", "ImageDirectoryEntryException",
    "ImageDirectoryEntrySecurity",
]

SUSPICIOUS_IMPORTS = [
    "VirtualAlloc", "VirtualProtect", "WriteProcessMemory",
    "CreateRemoteThread", "ShellExecute", "WinExec",
    "RegSetValue", "URLDownloadToFile", "socket", "connect",
    "CreateProcess", "OpenProcess", "NtUnmapViewOfSection"
]

def get_entropy(data):
    if not data:
        return 0.0
    freq = {}
    for b in data:
        freq[b] = freq.get(b, 0) + 1
    entropy = 0.0
    for count in freq.values():
        p = count / len(data)
        entropy -= p * math.log2(p)
    return entropy

def extract_features(file_bytes):
    features = {name: 0.0 for name in FEATURE_NAMES}
    try:
        pe = pefile.PE(data=file_bytes)
        dos = pe.DOS_HEADER
        for field in ["e_magic","e_cblp","e_cp","e_crlc","e_cparhdr",
                      "e_minalloc","e_maxalloc","e_ss","e_sp","e_csum",
                      "e_ip","e_cs","e_lfarlc","e_ovno","e_oemid",
                      "e_oeminfo","e_lfanew"]:
            features[field] = getattr(dos, field, 0)
        fh = pe.FILE_HEADER
        features["NumberOfSections"]     = fh.NumberOfSections
        features["PointerToSymbolTable"] = fh.PointerToSymbolTable
        features["NumberOfSymbols"]      = fh.NumberOfSymbols
        features["SizeOfOptionalHeader"] = fh.SizeOfOptionalHeader
        features["Characteristics"]      = fh.Characteristics
        if hasattr(pe, "OPTIONAL_HEADER"):
            opt = pe.OPTIONAL_HEADER
            for field in ["Magic","MajorLinkerVersion","MinorLinkerVersion",
                          "SizeOfCode","SizeOfInitializedData","SizeOfUninitializedData",
                          "AddressOfEntryPoint","BaseOfCode","ImageBase",
                          "SectionAlignment","FileAlignment",
                          "MajorOperatingSystemVersion","MinorOperatingSystemVersion",
                          "MajorImageVersion","MinorImageVersion",
                          "MajorSubsystemVersion","MinorSubsystemVersion",
                          "SizeOfHeaders","CheckSum","SizeOfImage","Subsystem",
                          "DllCharacteristics","SizeOfStackReserve","SizeOfStackCommit",
                          "SizeOfHeapReserve","SizeOfHeapCommit","LoaderFlags",
                          "NumberOfRvaAndSizes"]:
                features[field] = getattr(opt, field, 0)
            if hasattr(opt, "DATA_DIRECTORY"):
                dd = opt.DATA_DIRECTORY
                features["ImageDirectoryEntryExport"]    = dd[0].Size if len(dd) > 0 else 0
                features["ImageDirectoryEntryImport"]    = dd[1].Size if len(dd) > 1 else 0
                features["ImageDirectoryEntryResource"]  = dd[2].Size if len(dd) > 2 else 0
                features["ImageDirectoryEntryException"] = dd[3].Size if len(dd) > 3 else 0
                features["ImageDirectoryEntrySecurity"]  = dd[4].Size if len(dd) > 4 else 0
        if pe.sections:
            entropies  = [get_entropy(s.get_data()) for s in pe.sections]
            raw_sizes  = [s.SizeOfRawData           for s in pe.sections]
            virt_sizes = [s.Misc_VirtualSize         for s in pe.sections]
            phys       = [s.PointerToRawData         for s in pe.sections]
            virt       = [s.VirtualAddress           for s in pe.sections]
            ptr_data   = [s.PointerToRelocations     for s in pe.sections]
            chars      = [s.Characteristics          for s in pe.sections]
            features["SectionMinEntropy"]     = min(entropies)
            features["SectionMaxEntropy"]     = max(entropies)
            features["SectionMinRawsize"]     = min(raw_sizes)
            features["SectionMaxRawsize"]     = max(raw_sizes)
            features["SectionMinVirtualsize"] = min(virt_sizes)
            features["SectionMaxVirtualsize"] = max(virt_sizes)
            features["SectionMaxPhysical"]    = max(phys)
            features["SectionMinPhysical"]    = min(phys)
            features["SectionMaxVirtual"]     = max(virt)
            features["SectionMinVirtual"]     = min(virt)
            features["SectionMaxPointerData"] = max(ptr_data)
            features["SectionMinPointerData"] = min(ptr_data)
            features["SectionMaxChar"]        = max(chars)
            features["SectionsLength"]        = len(pe.sections)
            suspicious_names = [".upx", ".aspack", ".adata", "UPX"]
            features["SuspiciousNameSection"] = sum(
                1 for s in pe.sections
                if s.Name.decode(errors="ignore").strip("\\x00") in suspicious_names
            )
        if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"):
            imports = pe.DIRECTORY_ENTRY_IMPORT
            features["DirectoryEntryImport"]     = 1
            features["DirectoryEntryImportSize"] = len(imports)
            all_imports = []
            for entry in imports:
                for imp in entry.imports:
                    if imp.name:
                        all_imports.append(imp.name.decode(errors="ignore"))
            features["SuspiciousImportFunctions"] = sum(
                1 for fn in all_imports if fn in SUSPICIOUS_IMPORTS
            )
        if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"):
            features["DirectoryEntryExport"] = 1
        pe.close()
    except Exception as e:
        st.warning(f"⚠️ Feature extraction partial: {e}")
    return features

# ── UI ─────────────────────────────────────────────────────────────────────
st.title("🛡️ Malware Scanner")
st.caption("Upload a Windows PE executable (.exe or .dll) for instant static analysis. The file is never executed.")

uploaded_file = st.file_uploader("Drop your file here", type=["exe", "dll"])

if uploaded_file:
    file_bytes = uploaded_file.read()
    st.info(f"📁 **{uploaded_file.name}** — {len(file_bytes)/1024:.1f} KB")
    with st.spinner("Extracting PE features..."):
        features = extract_features(file_bytes)
    feature_vector = np.array([[features[f] for f in FEATURE_NAMES]])
    scaled         = scaler.transform(feature_vector)
    prediction     = model.predict(scaled)[0]
    proba          = model.predict_proba(scaled)[0]
    st.divider()
    if prediction == 1:
        st.error("## 🔴 MALWARE DETECTED")
    else:
        st.success("## 🟢 BENIGN")
    col1, col2 = st.columns(2)
    col1.metric("Benign probability",  f"{proba[0]*100:.1f}%")
    col2.metric("Malware probability", f"{proba[1]*100:.1f}%")
    st.divider()
    st.subheader("📊 Feature Breakdown")
    importances = model.feature_importances_
    top_idx     = np.argsort(importances)[::-1][:15]
    fig = go.Figure(go.Bar(
        x=[features[FEATURE_NAMES[i]] for i in top_idx],
        y=[FEATURE_NAMES[i]           for i in top_idx],
        orientation="h",
        marker_color=["#ff4d6d" if prediction == 1 else "#00e676"] * 15
    ))
    fig.update_layout(
        title="Top 15 Features (by model importance)",
        xaxis_title="Raw Feature Value",
        yaxis=dict(autorange="reversed"),
        height=500,
        margin=dict(l=10, r=10, t=40, b=10)
    )
    st.plotly_chart(fig, use_container_width=True)
    with st.expander("🔍 All extracted features"):
        df = pd.DataFrame(features.items(), columns=["Feature", "Value"])
        st.dataframe(df, use_container_width=True)
'''

with open("scanner_app.py", "w") as f:
    f.write(app_code)
print("✅ scanner_app.py written")

# ── Step 4: Launch Streamlit + ngrok tunnel ───────────────────────────────
import subprocess, time
from pyngrok import ngrok

NGROK_TOKEN = "your_authtoken_here"   # ← paste your token from dashboard.ngrok.com
ngrok.set_auth_token(NGROK_TOKEN)
ngrok.kill()

process = subprocess.Popen(
    ["streamlit", "run", "scanner_app.py",
     "--server.port", "8501",
     "--server.headless", "true",
     "--server.enableCORS", "false"],
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL
)
time.sleep(4)

public_url = ngrok.connect(8501)
print(f"\n{'='*55}")
print(f"  🛡️  MALWARE SCANNER IS LIVE")
print(f"  👉  {public_url}")
print(f"{'='*55}")
print("\n  Test with: C:\\Windows\\System32\\notepad.exe")
To stop the scanner when you are done, run this in a new cell:

process.terminate(); ngrok.kill(); print("🛑 Scanner stopped")

Getting your ngrok authtoken

1
Go to dashboard.ngrok.com/signup and create a free account (no payment needed)
2
After login, go to dashboard.ngrok.com/get-started/your-authtoken
3
Copy your token and paste it into NGROK_TOKEN = "your_authtoken_here"
4
Run the full cell — a public URL like https://abc123.ngrok.io will print at the bottom

Safe files to test with

These Windows system files should all return BENIGN — if they don't, it reflects the model's training imbalance (only 1 real benign sample), not a bug in the scanner:

  • C:\Windows\System32\notepad.exe
  • C:\Windows\System32\calc.exe
  • C:\Windows\System32\cmd.exe

Why the model may flag legitimate files

As established in Section 4, the training dataset contains 9,058 malware samples and only 1 benign sample. The model has never truly learned what benign PE files look like, so it tends to classify almost everything as malware with high confidence. The scanner pipeline itself is correct — the fix is to retrain with a balanced dataset containing thousands of real benign executables (e.g. from the EMBER dataset at github.com/elastic/ember which contains 300,000 benign PE samples).

What each part of the UI shows

Verdict banner

🔴 MALWARE DETECTED or 🟢 BENIGN — the Random Forest majority vote across all 100 trees

Probability metrics

Benign % and Malware % — the fraction of trees that voted each way. 100% means unanimous.

Feature breakdown

Bar chart of the top 15 features ranked by model importance, with the raw extracted value for this file

All features table

Expandable table showing all 74 extracted PE header values — useful for debugging or inspection

What you have built: A complete end-to-end malware detection system — from raw dataset → EDA → feature engineering → model training → model persistence → live web scanner. This is the same architecture used by commercial static analysis tools, just at a smaller scale and with a dataset that needs balancing.