Build a Process Anomaly Detector
Build a CLI that uses ML and a local AI to detect anomalous processes and diagnose your system.
Introduction
30 Second Summary
Your computer runs hundreds of processes at any given moment. Most are harmless, but a single rogue process burning through CPU or hogging memory can grind your machine to a halt.
In this project, you will build syswatch, a command-line tool that uses machine learning and a local AI model to detect unusual processes, diagnose security threats, and tell you exactly how to fix them.
What You'll Build
With a single command, you scan every running process on your machine, check disk and memory usage, spot security threats, and get a plain-English AI report telling you exactly what to fix and how.
By the end of this project, you'll have:
- Train an Isolation Forest model on your own system's baseline data. It learns what "normal" looks like on your machine.
- Scan every running process and instantly see which ones the model flags as unusual, ranked by anomaly score.
- Run an AI-powered analysis that diagnoses security threats, explains why your machine is slow, shows what is eating disk space, and gives you exact commands to fix it. All running locally with no internet required.
- Secret Mission: Add a --watch mode that continuously monitors processes and alerts on new anomalies.
Do I need machine learning experience?
No ML experience is needed. You should be comfortable with Python basics like functions, loops, and dictionaries.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your ML Environment
Every machine learning project starts with the right set of libraries. This project relies on Python packages for reading process data, training an anomaly detection model, and saving it to disk. It also uses Ollama to run a local AI model that analyzes your system with no internet required.
In this step, you'll confirm your Python version, install the required libraries, set up Ollama with a local AI model, and create the project folder where your anomaly detector will live.
In this step, get ready to:
- Confirm Python 3.11 or higher is installed.
- Install psutil, scikit-learn, pandas, joblib, and ollama.
- Install Ollama and pull a local AI model.
- Create the syswatch project directory and verify the setup.
Verify your Python version and install packages
pandas 3.0.5 requires Python 3.11 or higher. Let's confirm your version before installing anything.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.11 or higher
Your Python version is good to go. Move on to installing the packages below.
ⓧ I see an older version
You have Python installed, but this project needs 3.11 or higher. Your system's default python3 may point to an older version even after you install a newer one.
- Install Python 3.11 or higher using your distribution's package manager or a version manager like pyenv.
- Visit python.org/downloads for official installation instructions.
- After installing, run python3 --version again. If it still shows the old version, try python3.11 --version or python3.12 --version to confirm the new binary is available.
ⓧ Command not found
Python is not installed on your machine. You need version 3.11 or higher.
- Visit python.org/downloads and follow the instructions for your Linux distribution.
- After installing, run python3 --version to confirm you see 3.11 or higher before continuing.
Now that Python is ready, you can install the five libraries this project depends on.
- Install all five dependencies by running this command:
pip install psutil==7.2.2 scikit-learn==1.9.0 pandas==3.0.5 joblib==1.5.3 ollama
You should see progress bars as each package downloads and installs. The output ends with a line listing all successfully installed packages.
What does each package do?
- psutil reads live process data from your operating system: CPU usage, memory, threads, and network connections.
- scikit-learn provides the Isolation Forest algorithm you'll use for anomaly detection.
- pandas structures process data into tables (DataFrames) that the model can learn from.
- joblib saves and loads the trained model to and from disk so you don't retrain every time.
- ollama is the Python client for Ollama, letting your script talk to the local AI model for system analysis.
🙋♀️ Seeing permission errors or pip not found?
- If you see a permission error, try adding --user to the end of the install command to install packages for your user only.
- If pip is not found, try pip3 instead. Some Linux distributions use pip3 for Python 3.
Still stuck? Help me troubleshoot my pip install on Linux.
Install Ollama and pull a local AI model
Ollama runs AI models locally on your machine. Once the model is downloaded, everything runs offline with no internet connection needed.
- Install Ollama by running this command:
curl -fsSL https://ollama.com/install.sh | sh
You should see installation progress followed by a success message. Ollama starts running as a background service automatically.
- Pull the llama3.2:3b model by running this command. This is a ~2GB download and only needs to happen once:
ollama pull llama3.2:3b
You should see a progress bar as the model downloads. Once it finishes, the model is stored locally and available offline.
- Verify the model is ready by running:
ollama list
You should see llama3.2:3b listed with its size. From this point on, the AI runs entirely on your machine with no internet required.
Why run AI locally?
Cloud AI services send your data to external servers. For a security tool that scans your running processes, open ports, and disk contents, keeping everything local means your system data never leaves your machine.
🙋♀️ Ollama command not found?
- Close your terminal and open a new one. The install script may have updated your PATH but the current session does not reflect it yet.
- If the download failed partway through, run the install command again. It is safe to re-run.
Help me troubleshoot why the ollama command is not found after running the install script.
💡 How much RAM does the AI model need?
The llama3.2:3b model needs about 4GB of free RAM to run. If your machine has less than 8GB total, the AI responses may be slow. The anomaly detection part of the project works fine regardless of RAM.
Create your project directory and main file
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir syswatch
cd syswatch
- Create the main Python file by running:
touch syswatch.py
- Confirm the file exists by running:
ls
You should see syswatch.py listed in the output.
Verify psutil can read your system
Before building anything complex, let's confirm that psutil can actually read data from your operating system.
- Open syswatch.py in your code editor.
- Add the following code to the file:
import psutil
print(psutil.cpu_percent(interval=1))
What does this code do?
- import psutil loads the library that can read your system's process and hardware data.
- psutil.cpu_percent(interval=1) measures your CPU usage over a 1-second window and returns it as a percentage.
- Save syswatch.py.
Before you run this, what do you think the output will look like? Will it be a whole number, a decimal, or something else entirely?
- Run the script by executing this command in your terminal:
python3 syswatch.py
You should see a single decimal number printed, like 4.2 or 12.7. That's your system's CPU usage percentage over the last second.
Not seeing a number?
- If you see ModuleNotFoundError: No module named 'psutil', the install did not target the same Python version you're running. Try pip3 install psutil==7.2.2 and run the script again.
- Make sure you saved the file before running it. Unsaved changes won't be picked up by the interpreter.
- Check that your terminal is inside the syswatch folder. Run pwd to confirm you see syswatch at the end of the path.
Still stuck? Help me debug why python3 syswatch.py is not printing a CPU percentage.
✔️ Awesome, I've got everything!
Great. Double check you've saved syswatch.py.
ⓧ I'd like to double check the full code
Here is the complete syswatch.py so far. Make sure yours matches exactly.
import psutil
print(psutil.cpu_percent(interval=1))
Your environment is ready and psutil can read live data from your machine. Next up, you'll build a full process scanner that captures CPU, memory, threads, and network connections for every running process.
Build the Process Scanner
In the last step, you confirmed that psutil can read your system's data. Now you need to turn that raw data into structured numbers a machine learning model can learn from.
Every running process on your machine has measurable features: how much CPU it uses, how much memory it occupies, how many threads it runs, and how many network connections it holds. In this step, you will build a function that reads every process and converts it into a row of these features inside a pandas DataFrame.
In this step, get ready to:
- Write a function that reads every running process and extracts its features into a DataFrame.
- Add a formatted table printer and a main block that displays a live process snapshot.
Write the collect_snapshot() function
Right now syswatch.py only reads a single CPU percentage. You need to replace it with the full foundation your tool will build on: imports for every library you installed, configuration constants, and a utility function.
- Open syswatch.py in your code editor.
- Select all the existing code and delete it.
- Replace it with the following imports, constants, and helper function:
#!/usr/bin/env python3
"""syswatch - Process Anomaly Detector for Linux"""
import argparse
import os
import sys
import time
import joblib
import pandas as pd
import psutil
from sklearn.ensemble import IsolationForest
# Paths for storing baseline data and the trained model
DATA_DIR = os.path.expanduser("~/.syswatch")
MODEL_PATH = os.path.join(DATA_DIR, "model.joblib")
BASELINE_PATH = os.path.join(DATA_DIR, "baseline.csv")
# Features the model will learn from
FEATURE_COLS = [
"cpu_percent",
"memory_mb",
"memory_percent",
"num_threads",
"num_connections",
]
def ensure_data_dir():
"""Create the data directory if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
What does this code do?
- The imports bring in every library you installed: psutil for process data, pandas for DataFrames, IsolationForest for the ML model, and joblib to save it to disk.
- DATA_DIR, MODEL_PATH, and BASELINE_PATH define where your baseline data and trained model will be stored, inside a ~/.syswatch/ directory in your home folder.
- FEATURE_COLS lists the five numeric columns the model will learn from. Every process will be described by these five numbers.
- ensure_data_dir() creates the ~/.syswatch/ folder if it does not already exist.
- Save syswatch.py.
- Verify all the imports resolve by running this command:
python3 syswatch.py
You should see no output and no errors. A clean exit means every library imported successfully.
Seeing a ModuleNotFoundError?
- Check that you installed all four packages in Step 1. Run pip install psutil==7.2.2 scikit-learn==1.9.0 pandas==3.0.5 joblib==1.5.3 again to be sure.
- Make sure you are running the same python3 that pip installed into. If you have multiple Python versions, pip might have installed to a different one.
- Help me fix a ModuleNotFoundError when running syswatch.py
Now you can build the core data collection function. collect_snapshot() iterates over every running process, extracts five features from each, handles permission errors gracefully, and returns the results as a pandas DataFrame.
Because the function is long, you will add it in two parts. The first part loops through processes and extracts raw values for connections and memory.
- In syswatch.py, add the first part of collect_snapshot() below the ensure_data_dir() function:
def collect_snapshot():
"""Collect a snapshot of all running processes."""
rows = []
for proc in psutil.process_iter(
attrs=["pid", "name", "cpu_percent", "memory_percent", "num_threads"]
):
try:
info = proc.info
# Get network connections (needs root for other users' processes)
try:
num_connections = len(proc.net_connections(kind="inet"))
except (psutil.AccessDenied, psutil.NoSuchProcess):
num_connections = 0
# Get resident memory in megabytes
try:
rss_mb = proc.memory_info().rss / (1024 * 1024)
except (psutil.AccessDenied, psutil.NoSuchProcess):
rss_mb = 0.0
What does this code do?
- psutil.process_iter() loops through every running process on your machine and pre-fetches the attributes listed in attrs.
- proc.net_connections(kind="inet") counts active internet connections. On Linux, this may need root access for other users' processes, so it falls back to 0 on AccessDenied.
- proc.memory_info().rss returns Resident Set Size in bytes. Dividing by 1024 * 1024 converts it to megabytes.
Now add the second part of the function directly below. This part assembles each process's features into a dictionary and appends it to the rows list.
- Directly below the rss_mb = 0.0 line inside collect_snapshot(), add the following code. Make sure the indentation matches (three levels deep for rows.append):
# Build a feature row for this process
rows.append(
{
"pid": info["pid"],
"name": info["name"],
"cpu_percent": info["cpu_percent"] or 0.0,
"memory_mb": round(rss_mb, 2),
"memory_percent": info["memory_percent"] or 0.0,
"num_threads": info["num_threads"] or 0,
"num_connections": num_connections,
}
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Process disappeared mid-iteration, skip it
continue
return pd.DataFrame(rows)
What does this code do?
- rows.append() adds a dictionary with seven keys for each process: PID, name, and the five numeric features from FEATURE_COLS.
- The or 0.0 fallbacks handle cases where psutil returns None for a field. The model needs numbers, not empty values.
- The outer except catches processes that disappear while you are reading them. On a live system, processes start and stop constantly.
- pd.DataFrame(rows) converts the list of dictionaries into a structured table. Each row is one process, each column is one feature.
- Save syswatch.py.
- Verify that collect_snapshot() works by running this command:
python3 -c "from syswatch import collect_snapshot; df = collect_snapshot(); print(f'{len(df)} processes found')"
You should see a message like 245 processes found. The exact number depends on your machine. Any positive number means the function is working.
Seeing a SyntaxError or IndentationError?
- Double-check that rows.append( is indented three levels deep (12 spaces or 3 tabs). It sits inside the try: block, which is inside the for loop.
- Make sure the return pd.DataFrame(rows) line is indented one level (4 spaces), aligned with the for keyword above it.
- Help me fix an indentation or syntax error in my collect_snapshot function
Add print_snapshot() and the main block
The DataFrame holds your process data, but printing a raw DataFrame is not easy to read. You need a display function that formats it into an aligned table with clear column headers.
- Below collect_snapshot() in syswatch.py, add the print_snapshot() function:
def print_snapshot(df):
"""Print a process snapshot as a formatted table."""
# Print column headers
print(
f"\n{'PID':<8} {'Name':<20} {'CPU%':<8} {'Mem MB':<10} "
f"{'Threads':<9} {'Conns'}"
)
print("-" * 63)
# Print up to 20 rows of process data
for _, row in df.head(20).iterrows():
print(
f"{row['pid']:<8} {str(row['name'])[:19]:<20} "
f"{row['cpu_percent']:<8.1f} {row['memory_mb']:<10.1f} "
f"{row['num_threads']:<9} {row['num_connections']}"
)
if len(df) > 20:
print(f"... and {len(df) - 20} more processes")
print(f"\nTotal: {len(df)} processes")
What does this code do?
- The f-strings with :<8 and :<20 are width specifiers that pad each value to a fixed number of characters, creating aligned columns.
- df.head(20) limits the output to 20 rows so the table stays readable. If your machine has more processes, it prints a count of the rest.
- str(row['name'])[:19] truncates long process names so they fit in the 20-character column without breaking the table alignment.
The last piece is a prime_cpu() helper and a main block that ties everything together. psutil has a quirk: the first call to cpu_percent() always returns 0.0 because it has no prior sample to compare against. Priming calls it once, waits, and then the next reading is accurate.
- Below print_snapshot(), add the prime_cpu() function and the main block:
def prime_cpu():
"""Prime CPU percentages so the next reading is accurate."""
for proc in psutil.process_iter():
try:
proc.cpu_percent(interval=None)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
time.sleep(1)
if __name__ == "__main__":
# First call to cpu_percent always returns 0.0, so prime it
prime_cpu()
snapshot = collect_snapshot()
print_snapshot(snapshot)
Why do you need to prime CPU percentages?
CPU percentage is calculated by comparing two samples over time. The first call has nothing to compare against, so it returns 0.0. prime_cpu() makes that throwaway first call for every process and sleeps for 1 second so the next call gives a real reading.
Before you run this, what do you think the output will look like? What columns will you see, and how many rows?
- Save syswatch.py.
- Run your process scanner by running this command:
python3 syswatch.py
You should see a formatted table of your running processes with columns for PID, Name, CPU%, Mem MB, Threads, and Conns. The table shows up to 20 processes, followed by a total count.
Seeing all zeros in the CPU% column?
- Make sure prime_cpu() is called before collect_snapshot() in your if __name__ == "__main__" block. Without priming, the first CPU reading is always 0.0.
- If you see a table but no data appears, check that collect_snapshot() ends with return pd.DataFrame(rows). A missing return means the function gives back None.
- Help me debug my process scanner output in syswatch.py
✔️ Awesome, I've got everything!
Great. Double check you have saved syswatch.py.
ⓧ I'd like to double check the full code
Scroll up through your file and confirm these sections are present, in this order:
- The shebang line, docstring, and all imports (argparse, os, sys, time, joblib, pandas, psutil, IsolationForest).
- The four constants: DATA_DIR, MODEL_PATH, BASELINE_PATH, and FEATURE_COLS.
- The ensure_data_dir() function.
- The collect_snapshot() function (both parts: the data extraction and the rows.append block with the return statement).
- The print_snapshot() function.
- The prime_cpu() function.
- The if __name__ == "__main__" block calling prime_cpu(), collect_snapshot(), and print_snapshot().
Your process scanner can now read every running process and display its features in a clean table. Next up, you will train an Isolation Forest model on this data so it can learn what "normal" looks like on your machine.
Train the Anomaly Detector
Your process scanner can see every process running on your machine, but it has no idea what "normal" looks like. Without a baseline, every process is equally meaningless.
In this step, you'll collect multiple snapshots of your system to build a picture of typical behavior. Then you'll train an Isolation Forest model on that data and scan your running processes for anything unusual.
In this step, get ready to:
- Collect baseline snapshots of your system's processes.
- Train an Isolation Forest model on the baseline data.
- Scan running processes and view an anomaly report.
Collect your baseline data
A single snapshot only shows what your machine is doing right now. The model needs to see your system at multiple moments to learn the range of normal behavior.
- In syswatch.py, find the prime_cpu() function. Add the following collect_baseline() function directly below it:
def collect_baseline(num_snapshots=3, interval=2):
"""Collect multiple snapshots to build a baseline dataset."""
ensure_data_dir()
print(f"Collecting {num_snapshots} snapshots ({interval}s apart)...")
print("This teaches the model what 'normal' looks like on your machine.\n")
prime_cpu()
all_data = []
for i in range(num_snapshots):
print(f" Snapshot {i + 1}/{num_snapshots}...")
snapshot = collect_snapshot()
all_data.append(snapshot)
if i < num_snapshots - 1:
time.sleep(interval)
baseline = pd.concat(all_data, ignore_index=True)
baseline.to_csv(BASELINE_PATH, index=False)
print(f"\nBaseline saved: {len(baseline)} process samples")
print(f"Location: {BASELINE_PATH}")
What does this code do?
prime_cpu() runs first because psutil always returns 0.0 on the first CPU percentage call. Priming discards that stale reading so the real snapshots are accurate.
The loop takes 3 snapshots spaced 2 seconds apart, giving the model variety. All snapshots are concatenated into one large DataFrame and saved as a CSV to ~/.syswatch/baseline.csv.
To test baseline collection, you need to update the script's entry point. Right now, the if __name__ block runs the old snapshot-and-print logic from Step 2.
- Scroll to the bottom of syswatch.py and find this block:
if __name__ == "__main__":
prime_cpu()
snapshot = collect_snapshot()
print_snapshot(snapshot)
- Replace the entire body of that block so it looks like this:
if __name__ == "__main__":
collect_baseline()
- Save syswatch.py.
- Run the script by running this command:
python3 syswatch.py
- You should see three "Snapshot" progress lines, followed by a summary showing how many process samples were saved and the file path ~/.syswatch/baseline.csv. This takes about 7-8 seconds because of the CPU priming and snapshot intervals.
Not seeing any output?
Make sure you replaced the old if __name__ block body with the collect_baseline() call. If you still see old process table output, the old code is still running.
Check that collect_baseline() is placed after prime_cpu() at the module level (same indentation as the other def statements), not nested inside another function.
Help me debug my collect_baseline function
Train the model on your baseline
Now that you have baseline data on disk, you can train a model that learns the patterns in it. An Isolation Forest works by randomly partitioning data points. Normal points sit deep in the crowd and take many splits to isolate. Anomalies sit apart and get isolated quickly.
- In syswatch.py, add the train_model() function directly below collect_baseline():
def train_model(contamination='auto'):
"""Train an Isolation Forest on the baseline data."""
if not os.path.exists(BASELINE_PATH):
print("No baseline data found. Run: python3 syswatch.py baseline")
sys.exit(1)
baseline = pd.read_csv(BASELINE_PATH)
X = baseline[FEATURE_COLS].fillna(0)
model = IsolationForest(
n_estimators=100,
contamination=contamination,
random_state=42,
)
model.fit(X)
ensure_data_dir()
joblib.dump(model, MODEL_PATH)
print(f"Model trained on {len(X)} samples (contamination={contamination})")
print(f"Saved to {MODEL_PATH}")
What does this code do?
The function loads your baseline CSV, extracts the five feature columns (cpu_percent, memory_mb, memory_percent, num_threads, num_connections), and fits an Isolation Forest with 100 decision trees.
joblib.dump() saves the trained model to ~/.syswatch/model.joblib so you can load it later without retraining. The contamination parameter tells the model what fraction of points it should consider anomalous. Right now it is set to 'auto', which lets the algorithm decide on its own.
- Scroll to the if __name__ block at the bottom and find this line:
if __name__ == "__main__":
collect_baseline()
- Add train_model() on the next line so the block looks like this:
if __name__ == "__main__":
collect_baseline()
train_model()
- Save syswatch.py and run it:
python3 syswatch.py
- After the baseline snapshots finish, you should see a new line like Model trained on N samples (contamination=auto) followed by the saved model path.
Seeing a FileNotFoundError?
This means train_model() could not find baseline.csv. Make sure collect_baseline() is called before train_model() in the if __name__ block. The baseline must be collected first.
Help me fix my train_model function
Scan for anomalies
The model is trained and saved. Now you need a function that loads it, collects a fresh process snapshot, and asks the model to judge each process as normal or anomalous.
- In syswatch.py, add the first part of the scan() function directly below train_model(). This part loads the model and runs predictions:
def scan():
"""Scan current processes and flag anomalies."""
if not os.path.exists(MODEL_PATH):
print("No trained model found. Run:")
print(" python3 syswatch.py baseline")
print(" python3 syswatch.py train")
sys.exit(1)
model = joblib.load(MODEL_PATH)
prime_cpu()
snapshot = collect_snapshot()
X = snapshot[FEATURE_COLS].fillna(0)
predictions = model.predict(X)
scores = model.score_samples(X)
snapshot["anomaly"] = predictions
snapshot["score"] = scores.round(4)
anomalies = snapshot[snapshot["anomaly"] == -1].sort_values("score")
normal_count = len(snapshot[snapshot["anomaly"] == 1])
What does this code do?
model.predict(X) returns 1 for normal processes and -1 for anomalies. model.score_samples(X) gives each process a numeric anomaly score. Lower scores mean more anomalous.
The results are attached to the snapshot DataFrame so each row carries its PID, name, metrics, and the model's verdict. The anomalies are sorted by score so the most unusual processes appear first.
- Add the anomaly report output directly below the code you just pasted, still inside the scan() function (keep the indentation at one level):
print(f"\nScanned {len(snapshot)} processes")
print(f"Normal: {normal_count} | Anomalies: {len(anomalies)}\n")
if len(anomalies) > 0:
print("ANOMALIES DETECTED:")
print("-" * 72)
print(
f"{'PID':<8} {'Name':<20} {'CPU%':<8} {'Mem MB':<10} "
f"{'Threads':<9} {'Score'}"
)
print("-" * 72)
for _, row in anomalies.iterrows():
print(
f"{row['pid']:<8} {str(row['name'])[:19]:<20} "
f"{row['cpu_percent']:<8.1f} {row['memory_mb']:<10.1f} "
f"{row['num_threads']:<9} {row['score']:.4f}"
)
else:
print("No anomalies detected. Everything looks normal.")
What does this code do?
This section prints the anomaly report. If any processes were flagged as -1, they're printed in a table sorted by anomaly score, with the most unusual process at the top. If nothing was flagged, it prints an all-clear message.
Finally, update the entry point so the script runs the full pipeline: collect, train, scan.
- Scroll to the if __name__ block and find this code:
if __name__ == "__main__":
collect_baseline()
train_model()
- Add scan() on the line after train_model(), so the block looks like this:
if __name__ == "__main__":
collect_baseline()
train_model()
scan()
- Save syswatch.py.
Before you run this, think about what you expect the anomaly report to show. Your machine is just sitting here running normal processes. Do you think the model will flag anything?
- Run the full pipeline by running this command:
python3 syswatch.py
After the baseline collection and training, you'll see an anomaly report. It will flag several processes. Look at the names in the list.
Some of those flagged processes are clearly not anomalous. Your shell, your editor, system daemons that have been running quietly all along. The model flagged them anyway.
Why are normal processes being flagged?
The contamination='auto' setting lets the algorithm decide its own threshold for what counts as anomalous. Without a clear anomaly cluster in the data, it sets an arbitrary cutoff that flags too many normal processes.
This is expected. You need to prove the model can catch real anomalies, and then tune that threshold so it stops crying wolf. That is exactly what the next step does.
🙋♀️ Seeing no anomalies at all?
Make sure scan() is called after train_model() in the if __name__ block. Also check that both halves of the scan() function are indented consistently. The reporting section must be at the same indent level as the rest of the function body.
Help me debug my scan function
✔️ Awesome, I've got everything!
Great. Double check you've saved syswatch.py.
ⓧ I'd like to double check the full code
Your syswatch.py file is now too long to show in one block. Scroll through your file and confirm these sections are present, in order:
- Imports and constants (DATA_DIR, MODEL_PATH, BASELINE_PATH, FEATURE_COLS).
- ensure_data_dir() function.
- collect_snapshot() function.
- print_snapshot() function.
- prime_cpu() function.
- collect_baseline() function (added in this step).
- train_model() function with contamination='auto' (added in this step).
- scan() function (added in this step).
- if __name__ == "__main__" block calling collect_baseline(), train_model(), then scan().
The model flagged processes, but some of those flags look wrong. In the next step, you'll prove the model can catch a genuinely anomalous process, then tune the contamination parameter so it stops crying wolf.
Prove the Model Works
In the last step, you trained an Isolation Forest on your system's baseline and ran your first anomaly scan. The model flagged some processes, but look at the results more closely. Your shell, your editor, basic system services. These are clearly normal.
The contamination='auto' setting uses a threshold formula from the original Isolation Forest paper. For process data with no clear anomaly cluster, this produces arbitrary flags. In this step, you'll prove the model can catch genuinely unusual behavior by manufacturing a known anomaly, then tune the contamination parameter to stop the false positives.
In this step, get ready to:
- Detect a deliberately created CPU stress process as an anomaly.
- Tune the contamination parameter to reduce false positives and focus the anomaly list.
Start a CPU stress process and scan for it
Right now the model flags processes, but you have no way to know whether those flags are meaningful. To prove the detection is real, you need ground truth: a process that is obviously, unmistakably anomalous.
- Open a new terminal tab by pressing Ctrl+Shift+T, or open a second terminal window.
- Start a CPU stress process in the background by running this command:
python3 -c "while True: pass" &
Your terminal prints a line like [1] 12345. The number after the space is the PID of your stress process. Record it here: PID of your stress process.
What does this command do?
python3 -c "while True: pass" runs an infinite loop that burns 100% of one CPU core doing nothing useful. The & at the end runs it in the background so your terminal stays usable.
This creates exactly the kind of process your anomaly detector should catch: extreme resource usage with no legitimate purpose.
- Switch back to your original terminal where you have been running syswatch.
Before you run the next command, predict: do you think the model will catch the stress process among its anomalies?
- Run just the scan function (without re-collecting baseline or retraining) by running this command:
python3 -c "from syswatch import scan; scan()"
You should see the anomaly report with your stress process in the list. Its PID matches the one you recorded, and it likely appears near the top because of its extreme CPU usage.
But look at the rest of the list. The model probably flags dozens of other processes too, many of which are clearly normal. That is the contamination='auto' setting being too aggressive.
Why does 'auto' flag so many normal processes?
With contamination='auto', the model computes a threshold using a formula from the original Isolation Forest research paper. It does not know what percentage of your processes are actually anomalous.
For system process data where the vast majority of processes are normal, this formula sets the bar too low. Anything slightly different gets flagged, even if it is perfectly healthy.
🙋♀️ Stress process not in the anomaly list?
- Make sure the stress process is still running. In your second terminal, run jobs to check. If it shows Running, it is still active.
- Confirm you are running the scan from inside your syswatch/ project directory. The import statement needs to find syswatch.py in the current folder.
- Help me figure out why my stress process isn't being detected.
Tune the contamination parameter
The contamination parameter tells the model what fraction of the data it should consider anomalous. Setting it to 0.05 means roughly 5% of processes are expected to be anomalies. This focuses the model's attention on the most extreme outliers instead of flagging everything slightly different.
- In syswatch.py, find the train_model() function definition.
- Find this line:
def train_model(contamination='auto'):
- Replace 'auto' with 0.05, so the line now looks like this:
def train_model(contamination=0.05):
- Save syswatch.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
This step only changes one line. Confirm your train_model() function signature reads:
def train_model(contamination=0.05):
Everything else in syswatch.py stays exactly the same. The IsolationForest constructor inside the function body still uses contamination=contamination. It picks up the new default automatically.
Now retrain the model with the new setting and scan. Before you run the next command, predict: how will changing contamination from 'auto' to 0.05 affect the anomaly list?
- Retrain the model and run a fresh scan by running this command:
python3 -c "from syswatch import train_model, scan; train_model(); scan()"
The model retrains and scans. The anomaly list is now much shorter. Roughly 5% of processes are flagged, and the stress process is among them with the lowest anomaly score.
What does the anomaly score mean?
The score_samples() method returns a number for each process. Lower scores mean the model considers that process more anomalous.
Your stress process should have the lowest score in the entire list because its CPU usage is dramatically different from anything in the baseline. That score is the model's confidence that something is wrong.
🙋♀️ Still seeing too many anomalies?
- Make sure you saved syswatch.py after changing the default parameter. The python3 -c command reimports the file fresh, so unsaved changes will not take effect.
- Confirm the change is on the def train_model line, not inside the function body. The parameter default is what controls the value when no argument is passed.
- My anomaly list still looks too long after changing contamination. Help me debug.
Now that you have verified the model works, clean up the stress process.
- Kill the stress process by running this command:
kill [[STRESS_PID="PID of your stress process"]]
What if the process is already gone?
If you see a message about no such process, that means it already stopped. That is fine. You can double check by running ps aux | grep "while True" to confirm nothing is still running.
Your model now catches real anomalies and ignores the noise. Next up, you'll wrap everything into a proper CLI tool with subcommands so you can run baseline, train, scan, and check processes independently.
Add AI-Powered Analysis
Your anomaly detector catches unusual processes, but it cannot explain why they matter. It flags a process burning 100% CPU, but it does not tell you whether that is a crypto miner, a runaway build, or a system update.
In this step, you will connect the anomaly detector to a local AI model running through Ollama. The AI collects your full system picture (disk usage, memory, network connections, open ports, running processes) and delivers a plain-English report covering security threats, performance problems, and exactly how to fix them. Everything runs locally with no internet required.
In this step, get ready to:
- Write a function that collects disk, memory, network, and process data into a single system report.
- Send the report to a local AI model and print its analysis.
- Test the full analysis on your own machine.
Collect your full system picture
The anomaly detector only looks at process-level metrics. To give the AI enough context for a real diagnosis, you need to collect disk usage, memory breakdown, open network ports, and the anomaly scan results all in one place.
- In syswatch.py, find the scan() function. Add the following collect_system_info() function directly below it:
def collect_system_info():
"""Collect comprehensive system information for AI analysis."""
info = []
# Disk usage per partition
info.append("=== DISK USAGE ===")
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
info.append(
f" {part.mountpoint}: "
f"{usage.used / (1024**3):.1f}GB / "
f"{usage.total / (1024**3):.1f}GB "
f"({usage.percent}% full)"
)
except PermissionError:
continue
What does this code do?
psutil.disk_partitions(all=False) lists mounted disk partitions, skipping virtual filesystems. For each one, psutil.disk_usage() returns total size, used space, and percentage full.
The PermissionError catch handles partitions your user cannot read, like certain system mounts.
- Directly below the disk section you just pasted, still inside collect_system_info(), add the memory and top CPU consumers sections:
# Memory breakdown
mem = psutil.virtual_memory()
info.append("\n=== MEMORY ===")
info.append(
f" Total: {mem.total / (1024**3):.1f}GB "
f"Used: {mem.used / (1024**3):.1f}GB "
f"Available: {mem.available / (1024**3):.1f}GB "
f"({mem.percent}% used)"
)
# Top 10 CPU consumers
prime_cpu()
snapshot = collect_snapshot()
top_cpu = snapshot.nlargest(10, "cpu_percent")
info.append("\n=== TOP 10 CPU CONSUMERS ===")
for _, row in top_cpu.iterrows():
info.append(
f" PID {row['pid']} {str(row['name'])[:20]} "
f"CPU={row['cpu_percent']:.1f}%"
)
What does this code do?
psutil.virtual_memory() returns your system's total, used, and available RAM. The top CPU section reuses your existing collect_snapshot() function and sorts by CPU percentage to find the biggest resource hogs.
- Add the final sections for network connections, listening ports, and the anomaly scan. Paste this directly below the code you just added, still inside collect_system_info():
# Top 10 memory consumers
top_mem = snapshot.nlargest(10, "memory_mb")
info.append("\n=== TOP 10 MEMORY CONSUMERS ===")
for _, row in top_mem.iterrows():
info.append(
f" PID {row['pid']} {str(row['name'])[:20]} "
f"Mem={row['memory_mb']:.1f}MB"
)
# Listening ports (attack surface)
info.append("\n=== LISTENING PORTS ===")
for conn in psutil.net_connections(kind="inet"):
if conn.status == "LISTEN":
info.append(
f" Port {conn.laddr.port} "
f"(PID {conn.pid})"
)
# Anomaly scan results
if os.path.exists(MODEL_PATH):
model = joblib.load(MODEL_PATH)
X = snapshot[FEATURE_COLS].fillna(0)
preds = model.predict(X)
scores = model.score_samples(X)
snapshot["anomaly"] = preds
snapshot["score"] = scores.round(4)
flagged = snapshot[snapshot["anomaly"] == -1]
info.append(f"\n=== ANOMALY SCAN ({len(flagged)} flagged) ===")
for _, row in flagged.iterrows():
info.append(
f" PID {row['pid']} {str(row['name'])[:20]} "
f"score={row['score']:.4f}"
)
return "\n".join(info)
What does this code do?
The listening ports section uses psutil.net_connections(kind="inet") to find every open TCP/UDP port on your machine. These are your attack surface. Any port in LISTEN state is accepting connections from the network.
The anomaly scan reuses your trained Isolation Forest model. It loads the saved model, runs predictions on the current snapshot, and lists every process flagged as anomalous with its score.
- Save syswatch.py.
- Verify the function works by running this command:
python3 -c "from syswatch import collect_system_info; print(collect_system_info())"
You should see a multi-section report covering disk usage, memory, top CPU consumers, top memory consumers, listening ports, and any anomalies. This is the raw data the AI will analyze.
Seeing a NameError for collect_system_info?
Make sure collect_system_info() is placed after scan() at the module level (same indentation as the other def statements), not nested inside another function.
Help me fix a NameError when importing collect_system_info from syswatch.py
Send the report to the local AI
The raw data is collected. Now you need a function that sends it to the local Ollama model with a prompt that asks for a security and performance diagnosis.
- In syswatch.py, add the following import at the top of the file, below the existing from sklearn.ensemble import IsolationForest line:
from ollama import chat as ollama_chat
- Below collect_system_info(), add the analyze() function:
def analyze():
"""Run AI-powered system analysis using a local model."""
print("Collecting system data...\n")
system_info = collect_system_info()
prompt = f"""You are a Linux system security and performance expert.
Analyze the following system data and provide a report covering:
1. SECURITY THREATS: Suspicious processes, unusual network
connections, open ports that should not be exposed, and
any signs of malware or unauthorized access.
2. WHY THE COMPUTER IS SLOW: Which processes are consuming
the most CPU and memory, and whether that usage is normal.
3. DISK SPACE: What is consuming storage and what can be
safely cleaned up.
4. RECOMMENDATIONS: Specific commands the user can run to
fix each issue, improve performance, and harden security.
Be specific. Name exact processes, ports, and paths.
Give exact terminal commands for every recommendation.
SYSTEM DATA:
{system_info}
"""
print("Analyzing with local AI (this may take a moment)...\n")
print("=" * 60)
print("AI SYSTEM ANALYSIS REPORT")
print("=" * 60)
# Stream the AI response for real-time output
stream = ollama_chat(
model="llama3.2:3b",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
for chunk in stream:
print(chunk["message"]["content"], end="", flush=True)
print("\n" + "=" * 60)
What does this code do?
The function first calls collect_system_info() to gather all your system data into a single string. It then builds a detailed prompt telling the AI exactly what to look for: security threats, performance bottlenecks, disk hogs, and actionable fix commands.
ollama_chat() sends the prompt to the local llama3.2:3b model running on your machine through Ollama. With stream=True, the response prints word by word as the AI generates it, so you see results immediately instead of waiting for the full response.
💡 Why does this work offline?
Ollama runs the AI model entirely on your CPU and RAM. The ollama_chat() call connects to localhost:11434, which is your own machine. No data is sent to any external server. You can disconnect your internet and the analysis still works.
- Save syswatch.py.
Before you run this, predict: what do you think the AI will flag on your machine? Any processes you know are resource-heavy?
- Test the AI analysis by running this command:
python3 -c "from syswatch import analyze; analyze()"
You should see the AI streaming a detailed report covering security findings, performance analysis, disk usage breakdown, and specific terminal commands to fix each issue. The first run may take 30-60 seconds as the model loads into memory.
Seeing a ConnectionError?
This means Ollama is not running. Start the Ollama service by running ollama serve in a separate terminal, or check that it started automatically after installation.
If you see an error about the model not being found, run ollama pull llama3.2:3b to download it.
Help me fix a connection error when running the AI analysis in syswatch.py
Your anomaly detector now has an AI brain. It scans your system, spots the problems, and tells you exactly how to fix them. Next up, you will wrap everything into a proper CLI with subcommands so you can run baseline, train, scan, analyze, and check independently.
Build the CLI
You proved the model catches real anomalies, tuned it to stop crying wolf, and connected it to a local AI that diagnoses your entire system. But right now, you run each function by importing it manually in one-liner commands.
A real tool lets you run each action independently. You collect a fresh baseline when your machine is healthy, retrain when you want to update the model, and scan whenever you need to. In this step, you will turn the script into a proper CLI with subcommands using Python's argparse module.
In this step, get ready to:
- Replace the hardcoded script flow with an argparse CLI that has five subcommands.
- Write a function that inspects an individual process and reports whether it is normal or anomalous.
- Test the full workflow by running each subcommand independently.
Replace the if __name__ block with an argparse CLI
The current if __name__ block calls collect_baseline(), train_model(), and scan() every time. You will replace it with a main() function that uses argparse to route to the right function based on the subcommand the user types.
- In syswatch.py, scroll to the bottom and find this block:
if __name__ == "__main__":
collect_baseline()
train_model()
scan()
- Delete all four lines (the if __name__ line and the three function calls inside it).
- In their place, add the first half of the main() function by pasting this code:
def main():
# Define the CLI subcommands and their arguments
parser = argparse.ArgumentParser(
description="syswatch - Process Anomaly Detector"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
subparsers.add_parser("baseline", help="Collect baseline process data")
train_parser = subparsers.add_parser("train", help="Train the anomaly model")
train_parser.add_argument(
"--contamination",
type=float,
default=0.05,
help="Expected proportion of anomalies (default: 0.05)",
)
subparsers.add_parser("scan", help="Scan and flag anomalous processes")
subparsers.add_parser("analyze", help="AI-powered system analysis")
check_parser = subparsers.add_parser("check", help="Check a specific process")
check_parser.add_argument("pid", type=int, help="Process ID to check")
What does this code do?
argparse is Python's built-in module for building command-line interfaces. Each add_parser() call registers a subcommand the user can type after the script name.
- baseline and scan are simple subcommands with no extra arguments.
- train has a --contamination flag so the user can tune sensitivity without editing code.
- analyze runs the AI-powered system diagnosis using the local Ollama model.
- check takes a required pid argument so the user can inspect a specific process.
- Directly below the last line you just pasted (the check_parser.add_argument line), add the dispatch logic that completes the main() function:
# Route to the right function based on the subcommand
args = parser.parse_args()
if args.command == "baseline":
collect_baseline()
elif args.command == "train":
train_model(contamination=args.contamination)
elif args.command == "scan":
scan()
elif args.command == "analyze":
analyze()
elif args.command == "check":
check_process(args.pid)
else:
parser.print_help()
if __name__ == "__main__":
main()
What does this code do?
args.command holds whatever subcommand the user typed. The if/elif chain calls the matching function. If no subcommand is given, parser.print_help() prints usage instructions.
The if __name__ == "__main__" block now calls main() instead of running the three functions in sequence.
- Save syswatch.py.
- Test the new CLI by running the scan subcommand:
python3 syswatch.py scan
You should see the same anomaly report from earlier. The difference is that only the scan ran, not baseline collection and training.
Seeing a NameError for check_process?
If you accidentally ran python3 syswatch.py check 1 instead of scan, you would see this error because the check_process() function does not exist yet. You will add it in the next substep. Use scan for now.
help me debug a NameError in my syswatch.py argparse CLI
Write the check_process function
The scan command reports on all processes at once. But sometimes you want to investigate a single process by its PID. The check_process() function does exactly that.
- In syswatch.py, find the def main(): line you just added.
- Above def main():, add the first part of the check_process() function by pasting this code:
def check_process(pid):
"""Check a specific process for anomaly."""
# Make sure a trained model exists
if not os.path.exists(MODEL_PATH):
print("No trained model found. Run baseline and train first.")
sys.exit(1)
model = joblib.load(MODEL_PATH)
try:
# Prime CPU reading for this specific process
proc = psutil.Process(pid)
proc.cpu_percent(interval=None)
time.sleep(1)
try:
num_connections = len(proc.net_connections(kind="inet"))
except psutil.AccessDenied:
num_connections = 0
# Collect the same features the model was trained on
mem_info = proc.memory_info()
data = {
"cpu_percent": proc.cpu_percent(interval=None),
"memory_mb": round(mem_info.rss / (1024 * 1024), 2),
"memory_percent": proc.memory_percent(),
"num_threads": proc.num_threads(),
"num_connections": num_connections,
}
What does this code do?
This first half of check_process() loads the saved model and collects live feature data for a single process. It follows the same pattern as collect_snapshot() but targets one PID instead of every running process.
- psutil.Process(pid) creates a handle for the specific process.
- CPU priming is needed here too. The first cpu_percent() call always returns 0.0.
- The data dictionary holds the five feature columns the model expects.
- Directly below the last line you just pasted (the closing } of the data dictionary), add the prediction and output logic to complete the function:
# Run the model on this single process
X = pd.DataFrame([data])
prediction = model.predict(X)[0]
score = model.score_samples(X)[0]
# Print the detailed verdict
status = "ANOMALY" if prediction == -1 else "NORMAL"
print(f"\nProcess: {proc.name()} (PID {pid})")
print(f"Status: {status} (score: {score:.4f})")
print(f"CPU: {data['cpu_percent']:.1f}%")
print(f"Memory: {data['memory_mb']:.1f} MB ({data['memory_percent']:.1f}%)")
print(f"Threads: {data['num_threads']}")
print(f"Connections: {data['num_connections']}")
except psutil.NoSuchProcess:
print(f"Process {pid} not found.")
sys.exit(1)
What does this code do?
This second half wraps the single process's features in a DataFrame (the same format the model was trained on), runs prediction and scoring, then prints a detailed report.
- model.predict() returns 1 for normal or -1 for anomaly.
- model.score_samples() gives a numeric score. Lower scores mean the process looks more unusual to the model.
- If the PID no longer exists, psutil.NoSuchProcess is caught and a friendly message is printed.
- Save syswatch.py.
- First, find the PID of a running process to test with. Run this command to get the PID of your current shell:
echo $$
- Note the number it prints. This is your shell's PID.
- Test check by running this command, replacing the PID with the number you just noted:
python3 syswatch.py check [[SHELL_PID="the PID from the echo command above"]]
You should see a detailed report showing the process name, its NORMAL or ANOMALY status, the anomaly score, and its CPU, memory, threads, and connections stats.
Seeing 'Process not found' or an IndentationError?
- If you see "Process not found", make sure you are using a PID that is still running. Run echo $$ again to get a fresh PID from your current shell.
- If you see an IndentationError, check that both halves of check_process() are aligned. The second chunk's lines should start with 8 spaces (they are inside the try: block).
- Make sure check_process() is placed above def main():, not inside it. Both functions should start at the same indentation level.
help me fix my check_process function in syswatch.py
Test the full workflow
The CLI is complete. Each subcommand now runs independently. Before you finish, test the full end-to-end workflow the way a real user would.
Before you run the next four commands, predict: will the baseline and train steps create new files, or overwrite the existing ones in ~/.syswatch/?
- Collect a fresh baseline by running:
python3 syswatch.py baseline
You should see three snapshots collected and a message confirming the baseline was saved.
- Train the model on the new baseline by running:
python3 syswatch.py train
You should see a confirmation showing the sample count and contamination=0.05. The baseline and model files in ~/.syswatch/ are overwritten each time, so the model always reflects your latest baseline.
- Scan for anomalies by running:
python3 syswatch.py scan
You should see the anomaly report with a count of normal and anomalous processes.
- Finally, check an individual process by running:
python3 syswatch.py check [[SHELL_PID="the PID from the echo command above"]]
You should see the process name, its NORMAL or ANOMALY status, the anomaly score, and all five metrics printed.
Why run baseline and train again?
You already had a trained model from Step 4. Running baseline and train again confirms the CLI subcommands work independently. In practice, you would re-run baseline whenever you want to refresh what "normal" looks like on your machine.
✔️ Awesome, I've got everything!
Great. Double check you have saved syswatch.py and all four subcommands ran successfully.
ⓧ I'd like to double check the full code
Your syswatch.py is now too long to show in a single block. Scroll through your file and confirm these sections are present in this order:
- Imports and constants (DATA_DIR, MODEL_PATH, BASELINE_PATH, FEATURE_COLS), including from ollama import chat as ollama_chat.
- ensure_data_dir() function.
- collect_snapshot() function.
- print_snapshot() function.
- prime_cpu() function.
- collect_baseline() function.
- train_model() with contamination=0.05 as the default.
- scan() function.
- collect_system_info() function (added in Step 5).
- analyze() function with Ollama streaming (added in Step 5).
- check_process(pid) function.
- main() function with argparse subcommands for baseline, train, scan, analyze, and check.
- if __name__ == "__main__": main() at the very bottom.
Your anomaly detector is now a proper command-line tool. You can collect baselines, train models, scan all processes, run a full AI-powered system diagnosis, and inspect individual PIDs independently. That is the full pipeline: from data collection to a shipped, usable tool with built-in AI.
Secret mission
Add Continuous Watch Mode
Your anomaly detector runs once and exits. What if you could leave it running in the background, continuously scanning for new anomalies and alerting you the moment something unusual appears? In this mission, you add a --watch mode that turns syswatch into a live monitoring tool.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project directory ~/Desktop/syswatch/ containing syswatch.py.
- Data directory ~/.syswatch/ containing baseline.csv and model.joblib.
- Python packages: psutil, scikit-learn, pandas, and joblib.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep syswatch available for monitoring your system.
- Your project files stay in ~/Desktop/syswatch/.
- Your trained model and baseline data stay in ~/.syswatch/.
- The installed Python packages remain available for other projects.
Pause - I'll come back to this later
Since everything runs locally, there is nothing to pause. Your files and packages stay on your machine until you choose to delete them.
- When you come back, you can run python3 syswatch.py scan right away. Your trained model is already saved.
- To refresh the baseline after time away, run python3 syswatch.py baseline and then python3 syswatch.py train again.
Delete - I don't want to use this again
Remove all project files, saved model data, and installed packages to start fresh.
- Delete the project directory by running:
rm -rf ~/Desktop/syswatch
- Delete the saved model and baseline data by running:
rm -rf ~/.syswatch
- Uninstall the Python packages by running:
pip uninstall psutil scikit-learn pandas joblib -y
- Confirm everything is gone by running:
ls ~/Desktop/syswatch 2>/dev/null || echo "Project folder removed"
ls ~/.syswatch 2>/dev/null || echo "Data folder removed"
Nice Work!
Nice Work!
You just built a working process anomaly detector powered by machine learning. From collecting raw system data to training a model and shipping a real CLI tool, you completed the full ML pipeline on your own machine.
You learned how to:
- Collect live process data with psutil and engineer it into structured features, then train an Isolation Forest model that learned what "normal" looks like on your Linux machine.
- Discover that default model settings produce false positives, then tune the contamination parameter and prove the model catches real anomalies by creating and detecting a deliberate CPU stress test.
- Package everything into a polished CLI tool with argparse subcommands: baseline, train, scan, and check.
- Secret Mission: Added a continuous --watch mode that monitors processes and alerts on anomalies in real time.
Ready to quiz yourself?