Machine Learning for Document Classification in Python

Your shared drive has thousands of files, and someone has to decide which ones are invoices, contracts or support tickets.

Quick answer: machine learning for document classification turns each document into numbers (TF-IDF word weights) and trains a classifier on labeled examples, so it can sort new files on its own. In scikit-learn, that’s a two-step pipeline:

model = make_pipeline(TfidfVectorizer(), ComplementNB())
model.fit(train_texts, train_labels)
model.predict(["My 2019 Honda Accord stalls at red lights"])

In this tutorial, we’ll build machine learning for document classification step by step: load labeled documents, extract features, train and compare five models, read the scores, then classify and save.

What You Need Before You Start Classifying Documents

We ran every example on Python 3.12.5 with scikit-learn 1.9.1. Here’s the short list:

  • Python 3.10 or later and pip install scikit-learn joblib.
  • About 15 MB of disk space; scikit-learn downloads the 20 Newsgroups dataset on the first run and caches it.
  • A terminal. Our screenshots come from Command Prompt on Windows.

The running sample is 20 Newsgroups, a classic labeled set of real forum posts. We keep four topics, graphics, autos, medicine and space, so each one behaves like a document type in a company archive.

What is document classification in machine learning?

Document classification means assigning a document to one or more predefined categories. A model learns from documents someone has already labeled, which makes it supervised learning.

There are three flavors: binary sorts into two groups, like spam or not spam. Multi-class picks exactly one of several labels. Multi-label lets one document carry several tags, like an invoice that’s also a contract.

Scanned PDFs and images need one extra step first: OCR (optical character recognition) to pull out the text. After that, the pipeline is the same. For the wider NLP picture, see our guide to machine learning techniques for text.

Step 1: Load the Labeled Documents

fetch_20newsgroups() returns the texts, the numeric labels and the label names. We strip headers, footers and quoted replies, because they leak the answer (more on that in the mistakes section):

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

from collections import Counter

print(f"Training documents: {len(train.data):,}")
print(f"Test documents:     {len(test.data):,}")
counts = Counter(train.target)
for i, name in enumerate(train.target_names):
    print(f"  {name:<14} {counts[i]:>4} training docs")

empty = sum(1 for d in train.data if not d.strip())
print(f"Empty after cleanup: {empty}")

doc = " ".join(train.data[1].split())[:110]
print(f"\nDocument 1 ({train.target_names[train.target[1]]}):")
print(doc + "...")

Output:

Training documents: 2,365
Test documents:     1,575
  comp.graphics   584 training docs
  rec.autos       594 training docs
  sci.med         594 training docs
  sci.space       593 training docs
Empty after cleanup: 77

Document 1 (rec.autos):
Could be due to the rear-end ratio also. Usually automatics have different rear-ends than manuals, from my lim...
Command Prompt output of the 20 Newsgroups document classification dataset with training and test counts per category
Four balanced categories with about 590 training documents each, plus a held-out test set.

So we have 2,365 training and 1,575 test documents. The classes are balanced, and 77 documents ended up empty after the cleanup, which is normal for forum data. Cleaning text like this is part of data preprocessing in machine learning.

Note: for your own files, load a CSV with text and label columns. Our guide to reading a CSV file with pandas covers that part.

Step 2: Turn Documents into TF-IDF Features

Models need numbers, so TF-IDF (term frequency times inverse document frequency) gives a word a high weight when it’s frequent in one document but rare across the collection:

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

from sklearn.feature_extraction.text import TfidfVectorizer

vec = TfidfVectorizer(stop_words="english", sublinear_tf=True,
                      min_df=2, max_df=0.5)
X_train = vec.fit_transform(train.data)   # learn vocabulary on train
X_test = vec.transform(test.data)         # reuse it on test

print("Train matrix:", X_train.shape)
print("Test matrix: ", X_test.shape)
print(f"Non-zero cells: {X_train.nnz:,}")

terms = vec.get_feature_names_out()
row = X_train[1].toarray().ravel()
print("Top terms in document 1:")
for i in row.argsort()[::-1][:6]:
    print(f"  {terms[i]:<12} {row[i]:.3f}")

Output:

Train matrix: (2365, 13250)
Test matrix:  (1575, 13250)
Non-zero cells: 131,536
Top terms in document 1:
  rear         0.445
  anyways      0.351
  automatics   0.334
  manuals      0.322
  ends         0.317
  ratio        0.288

Each document is now a row with 13,250 columns, one per word. The top terms for the car post, like rear, automatics and manuals, show why this works.

We fit the vectorizer on the training set only and reuse it on the test set. stop_words="english" drops filler words, min_df=2 ignores one-off typos and max_df=0.5 drops words found in over half the documents. More options live in our feature extraction in machine learning guide.

Step 3: Train a Naive Bayes Document Classifier

A pipeline glues the vectorizer and the model together, so the same steps run at training and prediction time. Multinomial Naive Bayes is a fast baseline that works on word weights:

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score

model = make_pipeline(
    TfidfVectorizer(stop_words="english", sublinear_tf=True,
                    min_df=2, max_df=0.5),
    MultinomialNB(alpha=0.1),
)
model.fit(train.data, train.target)
pred = model.predict(test.data)
print(f"Naive Bayes accuracy: {accuracy_score(test.target, pred):.3f}")

Output:

Naive Bayes accuracy: 0.890

That’s 89.0% of 1,575 unseen documents labeled correctly. Accuracy alone can hide weak classes, though. Our scikit-learn accuracy_score guide explains when it misleads.

Step 4: Compare Five Document Classification Models

Next we swap the classifier and keep the same features. We tested both Naive Bayes variants, logistic regression in scikit-learn, a linear support vector machine and a random forest:

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB, ComplementNB
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score, f1_score

models = {
    "MultinomialNB": MultinomialNB(alpha=0.1),
    "ComplementNB": ComplementNB(alpha=0.3),
    "LogisticRegression": LogisticRegression(C=10, max_iter=1000),
    "LinearSVC": LinearSVC(C=0.5),
    "RandomForest": RandomForestClassifier(n_estimators=300,
                                           random_state=42),
}
print(f"{'Model':<20}{'Accuracy':>9}{'Macro F1':>10}")
for name, clf in models.items():
    pipe = make_pipeline(
        TfidfVectorizer(stop_words="english", sublinear_tf=True,
                        min_df=2, max_df=0.5), clf)
    pipe.fit(train.data, train.target)
    pred = pipe.predict(test.data)
    acc = accuracy_score(test.target, pred)
    f1 = f1_score(test.target, pred, average="macro")
    print(f"{name:<20}{acc:>9.3f}{f1:>10.3f}")

Output:

Model                Accuracy  Macro F1
MultinomialNB           0.890     0.890
ComplementNB            0.890     0.891
LogisticRegression      0.863     0.864
LinearSVC               0.864     0.865
RandomForest            0.803     0.804
Command Prompt output comparing accuracy and macro F1 of five scikit-learn document classification models
Both Naive Bayes models lead this small dataset, the linear models follow closely and the random forest trails.

This surprised us a bit. Most guides crown the linear SVM, but on 2,365 short documents ComplementNB had the best macro F1 at 0.891. LinearSVC scored 0.864 and the random forest 0.803.

Pro Tip: rerun this comparison on your own data. With tens of thousands of documents, linear models usually catch up.

Step 5: Evaluate the Classifier with a Report and Confusion Matrix

Now we take the winner, ComplementNB, and look at each class. Precision answers “when it says sci.med, how often is it right?” Recall answers “how many sci.med posts did it find?”:

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import ComplementNB
from sklearn.pipeline import make_pipeline
from sklearn.metrics import classification_report, confusion_matrix

model = make_pipeline(
    TfidfVectorizer(stop_words="english", sublinear_tf=True,
                    min_df=2, max_df=0.5),
    ComplementNB(alpha=0.3),
)
model.fit(train.data, train.target)
pred = model.predict(test.data)

print(classification_report(test.target, pred, digits=3,
                            target_names=train.target_names))
print("Confusion matrix (rows = true, columns = predicted):")
print(confusion_matrix(test.target, pred))

Output:

               precision    recall  f1-score   support

comp.graphics      0.796     0.951     0.867       389
    rec.autos      0.902     0.884     0.893       396
      sci.med      0.953     0.876     0.913       396
    sci.space      0.936     0.850     0.891       394

     accuracy                          0.890      1575
    macro avg      0.897     0.890     0.891      1575
 weighted avg      0.897     0.890     0.891      1575

Confusion matrix (rows = true, columns = predicted):
[[370  10   2   7]
 [ 32 350   5   9]
 [ 29  13 347   7]
 [ 34  15  10 335]]
Command Prompt output of a scikit-learn classification report and confusion matrix for a document classifier
Every class scores above 0.86 F1. The first column of the matrix shows where the misses go.

Most errors land in the comp.graphics column: 32 auto posts, 29 medical posts and 34 space posts were filed there. That’s why comp.graphics has the highest recall (0.951) but the lowest precision (0.796). Our scikit-learn confusion matrix tutorial shows how to plot it.

Step 6: Classify New Documents and Save the Model

We save the pipeline with joblib, load it back like a separate app would, and score four new 2026 support questions:

from sklearn.datasets import fetch_20newsgroups

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
KW = dict(categories=CATS, remove=("headers", "footers", "quotes"),
          shuffle=True, random_state=42)
train = fetch_20newsgroups(subset="train", **KW)
test = fetch_20newsgroups(subset="test", **KW)

import joblib
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import ComplementNB
from sklearn.pipeline import make_pipeline

model = make_pipeline(
    TfidfVectorizer(stop_words="english", sublinear_tf=True,
                    min_df=2, max_df=0.5),
    ComplementNB(alpha=0.3),
)
model.fit(train.data, train.target)
joblib.dump(model, "doc_classifier.joblib")

loaded = joblib.load("doc_classifier.joblib")
new_docs = [
    "My 2019 Honda Accord stalls at red lights in Phoenix. Is it "
    "the idle air valve or the transmission?",
    "NASA engineers are testing the Orion heat shield and the "
    "orbit plan before the next Artemis launch.",
    "Which GPU renders 3D models faster in Blender, and does "
    "OpenGL matter for ray tracing?",
    "My doctor in Boston says the new flu vaccine is safe with "
    "my blood pressure medication.",
]
probs = loaded.predict_proba(new_docs)
for doc, p in zip(new_docs, probs):
    best = p.argmax()
    label = train.target_names[best]
    print(f"{label:<14} {p[best]:6.1%}  {doc[:45]}...")

Output:

rec.autos       91.5%  My 2019 Honda Accord stalls at red lights in ...
sci.space       85.3%  NASA engineers are testing the Orion heat shi...
comp.graphics   80.1%  Which GPU renders 3D models faster in Blender...
sci.med         90.0%  My doctor in Boston says the new flu vaccine ...
Command Prompt output of a saved joblib document classification model labeling four new documents with confidence
The reloaded model sends each new document to the right category, with a confidence for every call.

All four landed in the right category, with confidence between 80.1% and 91.5%. We’d route low-confidence calls to a person. To serve the model over HTTP, see how to deploy a machine learning model as a REST API.

Document classification algorithms in scikit-learn: cheat sheet

Here’s what we measured on the same test set:

ModelAccuracyMacro F1Best for
ComplementNB0.8900.891Small or imbalanced text sets
MultinomialNB0.8900.890Fast baseline, word counts
LinearSVC0.8640.865Large sparse data, no probabilities
LogisticRegression0.8630.864Calibrated probabilities
RandomForest0.8030.804Mixed features, not raw text

How do you classify documents with more than one label?

A late-fee notice can be an invoice and a contract matter at once. For that, we turn the tags into a 0/1 matrix with MultiLabelBinarizer and train one classifier per tag:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import MultiLabelBinarizer

docs = [
    "Invoice 2026-114 for $4,250.00 is due March 15, 2026.",
    "Please pay the attached invoice by Friday.",
    "This agreement renews on July 1, 2026 unless canceled.",
    "Contract amendment: payment terms change to net 45.",
    "Invoice past due: late fee of $75.00 applies per contract.",
    "Signed lease agreement for the Denver office.",
    "Reminder: invoice 2026-201 balance is $980.50.",
    "The vendor contract includes an invoice schedule.",
]
tags = [["invoice"], ["invoice"], ["contract"], ["contract"],
        ["invoice", "contract"], ["contract"], ["invoice"],
        ["invoice", "contract"]]

mlb = MultiLabelBinarizer()
Y = mlb.fit_transform(tags)
model = make_pipeline(
    TfidfVectorizer(),
    OneVsRestClassifier(LogisticRegression(C=10)),
)
model.fit(docs, Y)

new = ["Contract requires each invoice within 30 days.",
       "Invoice 2026-310 total: $1,120.00."]
for doc, row in zip(new, model.predict(new)):
    print(mlb.classes_[row == 1].tolist(), "<-", doc)

Output:

['contract', 'invoice'] <- Contract requires each invoice within 30 days.
['invoice'] <- Invoice 2026-310 total: $1,120.00.

The first new document got both tags and the second only invoice. For a deep-learning version, read about large-scale multi-label text classification with Keras.

Common Mistakes to Avoid in Document Classification

The biggest one we reproduced is leakage: keeping text that gives the answer away. Newsgroup headers include the group name and the authors’ email signatures, so the model learns those instead of the topic:

from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.pipeline import make_pipeline

CATS = ["comp.graphics", "rec.autos", "sci.med", "sci.space"]
for remove in [(), ("headers", "footers", "quotes")]:
    kw = dict(categories=CATS, remove=remove)
    train = fetch_20newsgroups(subset="train", **kw)
    test = fetch_20newsgroups(subset="test", **kw)
    model = make_pipeline(TfidfVectorizer(sublinear_tf=True),
                          LinearSVC(C=0.5))
    model.fit(train.data, train.target)
    acc = model.score(test.data, test.target)
    print(f"remove={remove or 'nothing'}: accuracy {acc:.3f}")

Output:

remove=nothing: accuracy 0.947
remove=('headers', 'footers', 'quotes'): accuracy 0.863
Command Prompt output showing document classification accuracy inflated by headers compared with cleaned text
Same LinearSVC model: the score drops once headers, footers and quotes are removed.

With headers left in, LinearSVC looked like a 94.7% model. On clean text, it’s really 86.3%. Your own files leak the same way through file names, folder paths, email subjects or invoice numbers that contain the label.

Also fit the vectorizer on the training split only, and never score a model on documents it trained on.

Should you use deep learning for document classification?

For a few thousand documents and clear categories, the TF-IDF pipeline above is hard to beat on speed and cost. Transformer models pay off with subtle categories, longer documents or many languages. Our comparison of machine learning vs neural networks covers that trade-off.

If you want to try that route, start with our text classification with a Keras transformer build and compare it against these numbers.

Next Steps after building a document classifier

Swap in your own labeled documents and rerun Steps 4 and 5. To explain predictions to stakeholders, our guide to interpretable machine learning with Python is the natural next read.

We’d keep these machine learning guides open while you build:

Frequently asked questions

What is the best algorithm for document classification?

There isn’t one winner. On our 2,365-document test, ComplementNB scored 0.891 macro F1 and LinearSVC 0.865. Compare a few models on your own data.

How many labeled documents do I need?

A few hundred per category is a workable start. Our four categories had about 590 training documents each and reached 89% accuracy.

Can I classify PDF or scanned documents?

Yes. Extract the text first (a PDF text library, or OCR for scans), then feed that text to the same TF-IDF pipeline.

What’s the difference between text and document classification?

They use the same methods. Document classification usually means whole files, like invoices or reports, and may add layout or metadata features.

How do I save a trained document classifier?

Save the whole pipeline with joblib.dump(model, "doc_classifier.joblib") and load it with joblib.load(), so the vectorizer travels with the model.

Where can I learn more about text features in scikit-learn?

The official text feature extraction guide covers CountVectorizer, TfidfVectorizer and hashing.