Files
paperless-ngx/src/documents/classifier.py
T
Trenton H 530059c5c0 Performance: Preprocess classifier text with Tantivy instead of NLTK (#14127)
* Preprocesses classifier content with Tantivy instead of NLTK

Tokenizing and stemming now happen in one Rust call instead of NLTK's
Python tokenizer and per word stemming, which also removes the Redis
backed stem cache from every preprocessing call. The output matches the
NLTK pipeline closely; tokens containing digits are now stemmed, and the
English stop words follow Snowball's list.

Stemming and stop word removal apply whenever the OCR language is one of
the supported classifier languages, so PAPERLESS_ENABLE_NLTK and
PAPERLESS_NLTK_DIR are removed.

* Copies packages instead of hardlinking them in backend CI, some NLTK thing

* Adds a normalization to NFC to better fit what Tantivy expects
2026-09-16 07:35:26 -07:00

673 lines
26 KiB
Python

from __future__ import annotations
import functools
import hmac
import logging
import pickle
import re
import unicodedata
import warnings
from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Iterator
from datetime import datetime
from types import TracebackType
from typing import BinaryIO
from typing import Self
import tantivy
from numpy import ndarray
from sklearn.neural_network import MLPClassifier
from django.conf import settings
from django.core.cache import cache
from django.core.cache import caches
from django.db.models import Prefetch
from documents._snowball_stopwords import ENGLISH as ENGLISH_STOP_WORDS
from documents.caching import CACHE_5_MINUTES
from documents.caching import CACHE_50_MINUTES
from documents.caching import CLASSIFIER_HASH_KEY
from documents.caching import CLASSIFIER_MODIFIED_KEY
from documents.caching import CLASSIFIER_VERSION_KEY
from documents.models import Document
from documents.models import MatchingModel
from documents.models import Tag
from paperless.signed_pickle import SignedPickleError
from paperless.signed_pickle import signed_pickle_dumps
from paperless.signed_pickle import signed_pickle_loads
logger = logging.getLogger("paperless.classifier")
def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
"""
Return the predicted class id, or None if:
- the prediction is -1 (no match), or
- the winning class probability is below the configured threshold.
Using predict_proba() instead of predict() lets us apply a minimum-confidence
cutoff so that uncertain predictions are discarded rather than assigned.
"""
probas = classifier.predict_proba(X)[0]
best_idx = int(probas.argmax())
best_class = int(classifier.classes_[best_idx])
if best_class == -1:
return None
if threshold > 0.0 and probas[best_idx] < threshold:
return None
return best_class
read_cache = caches["read-cache"]
RE_WORD = re.compile(r"\b[\w]+\b") # words that may contain digits
# Documents whose content is fetched per query while training
_CONTENT_CHUNK_SIZE = 1000
class _SignedFileWriter:
"""
Atomically writes a file made of an HMAC signature followed by the data,
signing the data as it streams to disk rather than holding it in memory.
The signature is only known once everything is written, so its space is
reserved at the start of the file and filled in on exit. The target is only
replaced once the file is complete; on error the partial file is removed.
"""
def __init__(self, target: Path, mac: hmac.HMAC) -> None:
self._target = target
self._temp = target.with_name(f"{target.name}.part")
self._mac = mac
self._file: BinaryIO
def __enter__(self) -> Self:
self._file = self._temp.open("wb")
self._file.write(bytes(self._mac.digest_size))
return self
def write(self, data: bytes | memoryview) -> int:
self._mac.update(data)
return self._file.write(data)
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
try:
with self._file:
if exc_type is None:
self._file.seek(0)
self._file.write(self._mac.digest())
if exc_type is None:
self._temp.rename(self._target)
finally:
# A no-op after a successful rename, otherwise removes the partial file
self._temp.unlink(missing_ok=True)
@functools.cache
def _text_analyzer(language: str) -> tantivy.TextAnalyzer:
"""
Builds the cached analyzer for a language: word tokens, lowercase, stop words, stemmer.
Long tokens are kept and accents are not folded to ASCII, since stemmers
for languages such as French and German rely on accents.
"""
import tantivy
if language == "english":
# Tantivy's builtin English list is much shorter than Snowball's.
# Split contractions ("don't") on word characters, as content is, so they match
stop_words = tantivy.Filter.custom_stopword(
sorted({t for word in ENGLISH_STOP_WORDS for t in RE_WORD.findall(word)}),
)
else:
stop_words = tantivy.Filter.stopword(language)
return (
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.regex(r"\w+"))
.filter(tantivy.Filter.lowercase())
.filter(stop_words)
.filter(tantivy.Filter.stemmer(language))
.build()
)
class IncompatibleClassifierVersionError(Exception):
def __init__(self, message: str, *args: object) -> None:
self.message: str = message
super().__init__(*args)
class ClassifierModelCorruptError(Exception):
pass
def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | None:
if not settings.MODEL_FILE.is_file():
logger.debug(
"Document classification model does not exist (yet), not "
"performing automatic matching.",
)
return None
classifier = DocumentClassifier()
try:
classifier.load()
except IncompatibleClassifierVersionError as e:
logger.info(f"Classifier version incompatible: {e.message}, will re-train")
Path(settings.MODEL_FILE).unlink()
classifier = None
if raise_exception:
raise e
except ClassifierModelCorruptError as e:
# there's something wrong with the model file.
logger.exception(
"Unrecoverable error while loading document "
"classification model, deleting model file.",
)
Path(settings.MODEL_FILE).unlink()
classifier = None
if raise_exception:
raise e
except OSError as e:
logger.exception("IO error while loading document classification model")
classifier = None
if raise_exception:
raise e
except Exception as e: # pragma: no cover
logger.exception("Unknown error while loading document classification model")
classifier = None
if raise_exception:
raise e
return classifier
class DocumentClassifier:
# v7 - Updated scikit-learn package version
# v8 - Added storage path classifier
# v9 - Changed from hashing to time/ids for re-train check
# v10 - HMAC-signed model file
# v11 - Use sample_weight for balanced training; predict_proba with threshold;
# drop training-only MLP state before saving
# Tantivy text preprocessing
FORMAT_VERSION = 11
HMAC_SIZE = 32 # SHA-256 digest length
def __init__(self) -> None:
# last time a document changed and therefore training might be required
self.last_doc_change_time: datetime | None = None
# Hash of primary keys of AUTO matching values last used in training
self.last_auto_type_hash: bytes | None = None
self.data_vectorizer = None
self.data_vectorizer_hash = None
self.tags_binarizer = None
self.tags_classifier = None
self.correspondent_classifier = None
self.document_type_classifier = None
self.storage_path_classifier = None
def _update_data_vectorizer_hash(self) -> None:
self.data_vectorizer_hash = sha256(
pickle.dumps(self.data_vectorizer),
).hexdigest()
@staticmethod
def _new_hmac() -> hmac.HMAC:
return hmac.new(settings.SECRET_KEY.encode(), digestmod=sha256)
@staticmethod
def _strip_training_state(classifier: MLPClassifier) -> None:
"""
Drop MLPClassifier state which is only used during fit(), never by predict().
The Adam optimizer keeps two moment arrays the size of the weights, and
without early_stopping _best_coefs/_best_intercepts are just a copy of the
initial random weights. Together that is 3x the size of the weights, which
would otherwise be pickled and loaded along with the model.
"""
del classifier._optimizer
del classifier._best_coefs
del classifier._best_intercepts
@staticmethod
def _compute_hmac(data: bytes | memoryview) -> bytes:
mac = DocumentClassifier._new_hmac()
mac.update(data)
return mac.digest()
def load(self) -> None:
from sklearn.exceptions import InconsistentVersionWarning
raw = Path(settings.MODEL_FILE).read_bytes()
if len(raw) <= self.HMAC_SIZE:
raise ClassifierModelCorruptError
# Slice through a memoryview so the (potentially multi-GB) payload is
# not copied; hmac and pickle both accept buffers directly.
# The whole file is still verified from memory before unpickling, rather
# than streamed from disk, so it cannot change between check and load.
view = memoryview(raw)
signature = view[: self.HMAC_SIZE]
data = view[self.HMAC_SIZE :]
if not hmac.compare_digest(signature, self._compute_hmac(data)):
raise ClassifierModelCorruptError
# Catch warnings for processing
with warnings.catch_warnings(record=True) as w:
try:
(
schema_version,
self.last_doc_change_time,
self.last_auto_type_hash,
self.data_vectorizer,
self.tags_binarizer,
self.tags_classifier,
self.correspondent_classifier,
self.document_type_classifier,
self.storage_path_classifier,
) = pickle.loads(data)
except Exception as err:
raise ClassifierModelCorruptError from err
if schema_version != self.FORMAT_VERSION:
raise IncompatibleClassifierVersionError(
"Cannot load classifier, incompatible versions.",
)
self._update_data_vectorizer_hash()
# Check for the warning about unpickling from differing versions
# and consider it incompatible
sk_learn_warning_url = (
"https://scikit-learn.org/stable/"
"model_persistence.html"
"#security-maintainability-limitations"
)
for warning in w:
# The warning is inconsistent, the MLPClassifier is a specific warning, others have not updated yet
if issubclass(warning.category, InconsistentVersionWarning) or (
issubclass(warning.category, UserWarning)
and sk_learn_warning_url in str(warning.message)
):
raise IncompatibleClassifierVersionError("sklearn version update")
def save(self) -> None:
# Stream to disk instead of building the payload in memory. Protocol 5+
# pickles numpy arrays without copying them (the default is 4 before 3.14).
with _SignedFileWriter(settings.MODEL_FILE, self._new_hmac()) as f:
pickle.dump(
(
self.FORMAT_VERSION,
self.last_doc_change_time,
self.last_auto_type_hash,
self.data_vectorizer,
self.tags_binarizer,
self.tags_classifier,
self.correspondent_classifier,
self.document_type_classifier,
self.storage_path_classifier,
),
f,
protocol=pickle.HIGHEST_PROTOCOL,
)
def train(
self,
status_callback: Callable[[str], None] | None = None,
) -> bool:
notify = status_callback if status_callback is not None else lambda _: None
# Get non-inbox documents
docs_queryset = Document.objects.exclude(
tags__is_inbox_tag=True,
).order_by("pk")
# No documents exit to train against
doc_count = docs_queryset.count()
if doc_count == 0:
raise ValueError("No training data available.")
labels_tags = []
labels_correspondent = []
labels_document_type = []
labels_storage_path = []
# Content is fetched separately later, for exactly these documents in this
# order, so it never all has to be in memory at once
doc_pks: list[int] = []
latest_doc_change: datetime | None = None
# Step 1: Extract and preprocess training data from the database.
logger.debug("Gathering data from database...")
notify(f"Gathering data from {doc_count} document(s)...")
hasher = sha256()
for doc in (
docs_queryset.defer("content")
.select_related("document_type", "correspondent", "storage_path")
.prefetch_related(
Prefetch(
"tags",
queryset=Tag.objects.filter(
matching_algorithm=MatchingModel.MATCH_AUTO,
)
.order_by("pk")
.only("pk"),
to_attr="auto_tags",
),
)
.iterator(chunk_size=2000)
):
doc_pks.append(doc.pk)
if latest_doc_change is None or doc.modified > latest_doc_change:
latest_doc_change = doc.modified
y = -1
dt = doc.document_type
if dt and dt.matching_algorithm == MatchingModel.MATCH_AUTO:
y = dt.pk
hasher.update(y.to_bytes(4, "little", signed=True))
labels_document_type.append(y)
y = -1
cor = doc.correspondent
if cor and cor.matching_algorithm == MatchingModel.MATCH_AUTO:
y = cor.pk
hasher.update(y.to_bytes(4, "little", signed=True))
labels_correspondent.append(y)
tags: list[int] = [tag.pk for tag in doc.auto_tags]
for tag in tags:
hasher.update(tag.to_bytes(4, "little", signed=True))
labels_tags.append(tags)
y = -1
sp = doc.storage_path
if sp and sp.matching_algorithm == MatchingModel.MATCH_AUTO:
y = sp.pk
hasher.update(y.to_bytes(4, "little", signed=True))
labels_storage_path.append(y)
labels_tags_unique = {tag for tags in labels_tags for tag in tags}
num_tags = len(labels_tags_unique)
# Check if retraining is actually required.
# A document has been updated since the classifier was trained
# New auto tags, types, correspondent, storage paths exist
if (
self.last_doc_change_time is not None
and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest():
logger.info("No updates since last training")
# Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time
cache.set(
CLASSIFIER_MODIFIED_KEY,
self.last_doc_change_time,
CACHE_50_MINUTES,
)
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
return False
# subtract 1 since -1 (null) is also part of the classes.
# union with {-1} accounts for cases where all documents have
# correspondents and types assigned, so -1 isn't part of labels_x, which
# it usually is.
num_correspondents: int = len(set(labels_correspondent) | {-1}) - 1
num_document_types: int = len(set(labels_document_type) | {-1}) - 1
num_storage_paths: int = len(set(labels_storage_path) | {-1}) - 1
logger.debug(
f"{len(doc_pks)} documents, {num_tags} tag(s), {num_correspondents} correspondent(s), "
f"{num_document_types} document type(s). {num_storage_paths} storage path(s)",
)
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import LabelBinarizer
from sklearn.preprocessing import MultiLabelBinarizer
# MLPClassifier does not support class_weight directly
# (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use
# compute_sample_weight to balance classes during training and prevent
# over-represented correspondents from dominating predictions.
# https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html
from sklearn.utils.class_weight import compute_sample_weight
# Step 2: vectorize data
logger.debug("Vectorizing data...")
notify("Vectorizing document content...")
def content_generator() -> Iterator[str]:
"""
Generates the content for documents, in the same order as the labels,
fetching it a chunk at a time
"""
for start in range(0, len(doc_pks), _CONTENT_CHUNK_SIZE):
chunk = doc_pks[start : start + _CONTENT_CHUNK_SIZE]
docs = Document.objects.only("content").order_by().in_bulk(chunk)
for pk in chunk:
# A document deleted since its labels were gathered still
# needs a row, so labels and content stay aligned
doc = docs.get(pk)
yield self.preprocess_content(
doc.content if doc is not None else "",
)
self.data_vectorizer = CountVectorizer(
analyzer="word",
ngram_range=(1, 2),
min_df=0.01,
)
data_vectorized: ndarray = self.data_vectorizer.fit_transform(
content_generator(),
)
# See the notes here:
# https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html
# This attribute isn't needed to function and can be large
self.data_vectorizer.stop_words_ = None
# Step 3: train the classifiers
if num_tags > 0:
logger.debug("Training tags classifier...")
notify(f"Training tags classifier ({num_tags} tag(s))...")
if num_tags == 1:
# Special case where only one tag has auto:
# Fallback to binary classification.
labels_tags = [
label[0] if len(label) == 1 else -1 for label in labels_tags
]
self.tags_binarizer = LabelBinarizer()
labels_tags_vectorized: ndarray = self.tags_binarizer.fit_transform(
labels_tags,
).ravel()
else:
self.tags_binarizer = MultiLabelBinarizer()
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
self._strip_training_state(self.tags_classifier)
else:
self.tags_classifier = None
logger.debug("There are no tags. Not training tags classifier.")
if num_correspondents > 0:
logger.debug("Training correspondent classifier...")
notify(
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
)
self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
self.correspondent_classifier.fit(
data_vectorized,
labels_correspondent,
sample_weight=compute_sample_weight("balanced", labels_correspondent),
)
self._strip_training_state(self.correspondent_classifier)
else:
self.correspondent_classifier = None
logger.debug(
"There are no correspondents. Not training correspondent classifier.",
)
if num_document_types > 0:
logger.debug("Training document type classifier...")
notify(
f"Training document type classifier ({num_document_types} type(s))...",
)
self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
self.document_type_classifier.fit(
data_vectorized,
labels_document_type,
sample_weight=compute_sample_weight("balanced", labels_document_type),
)
self._strip_training_state(self.document_type_classifier)
else:
self.document_type_classifier = None
logger.debug(
"There are no document types. Not training document type classifier.",
)
if num_storage_paths > 0:
logger.debug(
"Training storage paths classifier...",
)
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
self.storage_path_classifier.fit(
data_vectorized,
labels_storage_path,
sample_weight=compute_sample_weight("balanced", labels_storage_path),
)
self._strip_training_state(self.storage_path_classifier)
else:
self.storage_path_classifier = None
logger.debug(
"There are no storage paths. Not training storage path classifier.",
)
self.last_doc_change_time = latest_doc_change
self.last_auto_type_hash = hasher.digest()
self._update_data_vectorizer_hash()
# Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time
cache.set(CLASSIFIER_MODIFIED_KEY, self.last_doc_change_time, CACHE_50_MINUTES)
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
return True
def preprocess_content(self, content: str) -> str:
"""
Process the contents of a document, distilling it down into
words which are meaningful to the content.
"""
language = settings.CLASSIFIER_LANGUAGE
content = unicodedata.normalize("NFC", content)
if language is None:
return " ".join(
match.group().lower() for match in RE_WORD.finditer(content)
)
return " ".join(_text_analyzer(language).analyze(content))
def _get_vectorizer_cache_key(self, content: str):
hash = sha256(content.encode())
hash.update(
f"|{self.FORMAT_VERSION}|{settings.CLASSIFIER_LANGUAGE}|{self.data_vectorizer_hash}".encode(),
)
return f"vectorized_content_{hash.hexdigest()}"
def _vectorize(self, content: str):
key = self._get_vectorizer_cache_key(content)
serialized_result = read_cache.get(key)
if serialized_result is None:
result = self.data_vectorizer.transform([self.preprocess_content(content)])
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else:
try:
result = signed_pickle_loads(serialized_result)
except SignedPickleError:
result = self.data_vectorizer.transform(
[self.preprocess_content(content)],
)
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
else:
read_cache.touch(key, CACHE_5_MINUTES)
return result
def predict_correspondent(self, content: str) -> int | None:
if self.correspondent_classifier:
X = self._vectorize(content)
predicted_id = _predict_with_threshold(
self.correspondent_classifier,
X,
settings.CLASSIFIER_MATCH_THRESHOLD,
)
return predicted_id
return None
def predict_document_type(self, content: str) -> int | None:
if self.document_type_classifier:
X = self._vectorize(content)
predicted_id = _predict_with_threshold(
self.document_type_classifier,
X,
settings.CLASSIFIER_MATCH_THRESHOLD,
)
return predicted_id
return None
def predict_tags(self, content: str) -> list[int]:
from sklearn.utils.multiclass import type_of_target
if self.tags_classifier:
X = self._vectorize(content)
y = self.tags_classifier.predict(X)
tags_ids = self.tags_binarizer.inverse_transform(y)[0]
if type_of_target(y).startswith("multilabel"):
# the usual case when there are multiple tags.
return list(tags_ids)
elif type_of_target(y) == "binary" and tags_ids != -1:
# This is for when we have binary classification with only one
# tag and the result is to assign this tag.
return [tags_ids]
else:
# Usually binary as well with -1 as the result, but we're
# going to catch everything else here as well.
return []
else:
return []
def predict_storage_path(self, content: str) -> int | None:
if self.storage_path_classifier:
X = self._vectorize(content)
predicted_id = _predict_with_threshold(
self.storage_path_classifier,
X,
settings.CLASSIFIER_MATCH_THRESHOLD,
)
return predicted_id
return None