Table of Contents
- Problem explanation — what is malware and why detect it with ML?
- Understanding the dataset — PE file features explained
- Importing libraries — your cybersecurity toolbox
- Loading & first look at the data
- ★ Collecting benign PE samples — System32, Program Files & combining with malware dataset
- Data analysis & exploration — understanding the feature space
- Preprocessing — dropping irrelevant columns & scaling features
- Train-test split — evaluating honestly with stratified sampling
- Training & comparing five ML algorithms — with ROC curves & SVM
- ★ Saving & loading the best model with pickle
- ★ Live PE feature analyser — try it right here
- ★ Streamlit scanner — drag-and-drop live malware detector
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.
Attaches itself to legitimate programs. Spreads when the infected program is run or shared. Classic example: ILOVEYOU (2000).
Disguises itself as useful software. Once installed, creates a backdoor or exfiltrates data. The victim installs it willingly.
Encrypts the victim's files and demands payment for the decryption key. WannaCry (2017) caused $4 billion in damages globally.
Self-replicating program that spreads across networks without any user interaction. Consumes bandwidth and disk space.
Silently monitors the user — keystrokes, screenshots, webcam footage — and sends the data to an attacker's server.
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.
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.
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
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.
Contains the Machine type (x86/x64), number of sections, TimeDateStamp (when the file was compiled), and characteristics flags describing the file type.
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.
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 Column | What it represents | Malware signal? |
|---|---|---|
| Name | Original filename of the executable (e.g. setup.exe) | DROPPED Filenames are meaningless — malware can be named anything |
| Machine | Target CPU architecture: 0x014c = x86 (32-bit), 0x8664 = x64 (64-bit) | DROPPED Most malware targets both; not a reliable signal |
| TimeDateStamp | Unix timestamp of when the linker compiled the file | DROPPED Easily forged; malware routinely sets this to 0 or a random date |
| SectionMainChar | Characteristics bitmap of the primary section | DROPPED Encoded flags — less informative than section count or entropy |
| SizeOfOptionalHeader | Byte size of the Optional Header — usually 224 (x86) or 240 (x64) | USEFUL Unusual sizes suggest tampering |
| SizeOfCode | Total byte size of all code sections combined | USEFUL Abnormally small code + large data = packed malware |
| SizeOfInitializedData | Size of data sections that have initial values | USEFUL Disproportionate ratios indicate unusual structure |
| AddressOfEntryPoint | Memory address where execution begins | USEFUL Entry point near section boundaries = packed binary |
| NumberOfSections | How many sections the PE file contains | USEFUL Most legitimate files: 4–6 sections; malware may have 1 or 15+ |
| DllCharacteristics | Security flags: ASLR support, DEP support, code integrity, etc. | USEFUL Malware often disables ASLR/DEP for easier exploitation |
| SectionMainEntropy | Entropy (randomness) of the main code section (0–8) | STRONG Entropy > 7.0 strongly suggests packing or encryption |
| SectionMinEntropy | Entropy of the least-random section | USEFUL Very low or very high minimum entropy is unusual |
| SectionMaxEntropy | Entropy of the most-random section | STRONG Max entropy near 8.0 = almost certainly packed or encrypted |
| SectionMinRawsize | Smallest raw section size in bytes | USEFUL Tiny sections are unusual in legitimate software |
| Malware | Target label: 0 = benign, 1 = malware | TARGET This is what we train the model to predict |
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.
| Library | Role in this project | Why it's needed |
|---|---|---|
| numpy | Numerical computing | Fast array operations used internally by every other library. Handles the heavy matrix arithmetic behind model training. |
| pandas | Data manipulation | Loads the CSV dataset into a DataFrame — a table-like structure we can slice, filter, and analyse with simple Python commands. |
| seaborn | Statistical visualisation | Creates polished plots (correlation heatmaps, boxplots, distribution charts) with very little code. Built on top of matplotlib. |
| matplotlib.pyplot | Core plotting engine | The foundational plotting library. Seaborn uses it under the hood. We call it directly for customising chart titles, axes, and size. |
| sklearn.metrics | Model evaluation | Provides accuracy_score, classification_report, and confusion_matrix — the tools we use to measure how well each model performs. |
| sklearn.model_selection | Data splitting | Provides 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.preprocessing | Feature scaling | Provides MinMaxScaler — normalises all feature values to the 0–1 range so that large-valued features don't dominate distance-based algorithms. |
| sklearn.ensemble | Ensemble models | Provides RandomForestClassifier — the most effective model for PE feature data. Ensemble = combining many decision trees. |
| sklearn.neighbors | Distance-based classifier | Provides KNeighborsClassifier — classifies based on similarity to training examples. |
| sklearn.linear_model | Linear classifier | Provides LogisticRegression — a probabilistic baseline model. Despite the name, it is a classification algorithm. |
| sklearn.tree | Single tree classifier | Provides DecisionTreeClassifier — the base learner inside Random Forest. Good for understanding how ensemble methods improve on individual trees. |
| pickle | Model serialisation | Saves a trained model to a file (.pkl) so it can be loaded later without retraining. Part of Python's standard library — no install needed. |
# ── 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")
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.
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.
# 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)")
# .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()
# .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
# 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()
stratify=targets during the train-test split later to ensure both sets maintain the same benign-to-malware ratio as the original dataset.
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.
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
Core Windows executables and DLLs — notepad.exe, cmd.exe, calc.exe, and hundreds of system DLLs. Gold-standard known-good files.
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.
Third-party 64-bit apps — browsers, editors, Office, etc. Richer variety of section sizes, import tables, and DllCharacteristics than system files alone.
32-bit third-party apps. Covers older compiler outputs and legacy PE patterns your model would otherwise never see.
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.
cmd, press Enter. A terminal window opens. Type the command below and press Enter.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:
winget install Python.Python.3
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.
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.
C:\Users\YourName\malware_project\. Place two files in this folder:
dataset_malwares.csv— the original Kaggle datasetcollect_benign.py— the collection script (Step 4 below)
collect_benign.py in the project folder. This is the complete, ready-to-run script — nothing else needs to be added.# ============================================================ # 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! 🎉")
cmd, and press Enter. This opens a terminal already pointing at that folder.python collect_benign.py
Done! 🎉, the file combined_dataset.csv has been saved in your project folder.
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.
combined_dataset.csv. Wait for the upload — you will see it appear in the file list.# Change FROM this: data = pd.read_csv("dataset_malwares.csv") # Change TO this: data = pd.read_csv("combined_dataset.csv")
data variable that Section 3 creates, so the balanced dataset flows through the entire pipeline automatically with no other changes needed.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".
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.
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.
# 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.
# 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()
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.
# 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.
# 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()
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:
# 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:
NumberOfSectionsranges from 1 to about 20 — small numbersSizeOfCodecan range from 0 to hundreds of millions — enormous numbersSectionMaxEntropyranges 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.
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.
# 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")
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.
# ── 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")
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.
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.
# ── 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.
# ── 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()
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.
# ── 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.
# ── 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
# 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()
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:
| Metric | Formula | Security meaning | Cost of failure |
|---|---|---|---|
| Precision (Malware) | True Malware / All Flagged as Malware | Of 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 Malware | Of 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-Score | 2 × (Precision × Recall) / (Precision + Recall) | Harmonic mean — penalises heavily when either precision or recall is low | Use this when you want a single number that balances both error types |
| Support | Count of actual samples of this class in the test set | Tells you how statistically meaningful the metric is | Metrics from small support values are unreliable — treat with caution |
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.
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()
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.
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 / Symptom | Likely cause | Fix |
|---|---|---|
KeyError: 'Malware' | Target column name differs in your CSV version | Run 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 float | A text column was not dropped before scaling | Confirm you dropped all four text/flag columns before fit_transform() |
ConvergenceWarning from Logistic Regression | Default 100 iterations not enough to converge | Pass max_iter=1000 to LogisticRegression() |
| Loaded model gives wrong predictions | Scaler was refit on new data — min/max values changed | Always call scaler_loaded.transform() — never fit_transform() — on production data |
| Training accuracy ~100%, test accuracy ~70% | Overfitting — model memorised training noise | Constrain tree depth with max_depth=15 or add more training data |
| Feature shape mismatch during prediction | New file's feature dict has different or reordered columns | Use features.columns.tolist() from training and build the input array in exactly that order |
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.
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.")
# ── 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}%")
# ── 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)
.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.
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.
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
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
| Feature | Typical benign range | Suspicious (malware-like) value | Reasoning |
|---|---|---|---|
| SectionMaxEntropy | 5.0 – 6.8 | > 7.2 | Packing/encryption inflates entropy close to the theoretical maximum of 8.0 |
| SectionMainEntropy | 4.5 – 6.5 | > 7.0 | Legitimate code has predictable structure; encrypted stubs do not |
| NumberOfSections | 4 – 8 | < 3 or > 10 | Packers create minimal sections; some droppers create many tiny sections |
| DllCharacteristics | 32832+ (ASLR+DEP enabled) | 0 or 32 | Malware disables ASLR/DEP to make exploitation easier after self-injection |
| SizeOfCode | Proportional to file size | Very small (<4 KB) | Packed malware has a tiny stub that decrypts the real payload at runtime |
| AddressOfEntryPoint | 4,096 – 100,000 | > 1,000,000 | Entry point deep in memory suggests manual mapping or process injection |
Going further — project extension ideas
- Extract real PE features: Install
pip install pefileand write a function that reads an actual.exefile 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
XGBClassifierfrom thexgboostpackage. 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
GridSearchCVto search for the optimaln_estimatorsandmax_depthfor Random Forest. Does tuning improve accuracy beyond 99%? - SHAP explanations: Install
pip install shapand useshap.TreeExplainerto 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
streamlitweb app where you drag and drop an.exefile and get an instant verdict with a feature breakdown chart.
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).
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.
# ══════════════════════════════════════════════════════════════════════════ # 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")
process.terminate(); ngrok.kill(); print("🛑 Scanner stopped")
Getting your ngrok authtoken
NGROK_TOKEN = "your_authtoken_here"https://abc123.ngrok.io will print at the bottomSafe 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.exeC:\Windows\System32\calc.exeC:\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
🔴 MALWARE DETECTED or 🟢 BENIGN — the Random Forest majority vote across all 100 trees
Benign % and Malware % — the fraction of trees that voted each way. 100% means unanimous.
Bar chart of the top 15 features ranked by model importance, with the raw extracted value for this file
Expandable table showing all 74 extracted PE header values — useful for debugging or inspection