-
Notifications
You must be signed in to change notification settings - Fork 0
Text 0.3.2 vectorization
Lodestar.Text 0.3.2. This page is frozen at that release. Read the current documentation for what
mainsays now. A link to a decision or a migration page followsmain, and leaves the archive.
Lodestar.Text.Vectorization reproduces sklearn.feature_extraction.text: it turns
a corpus of documents into a sparse matrix of features.
dotnet add package Lodestar.Textusing Lodestar.Text.Vectorization;
string[] docs =
[
"the cat eats",
"the dog eats",
"the cat and the dog",
];
var cv = new CountVectorizer();
CsrMatrix counts = cv.FitTransform(docs);
// Sorted vocabulary (like sklearn): ["and", "cat", "dog", "eats", "the"]
foreach (string f in cv.GetFeatureNames()) Console.Write($"{f} ");
double[,] dense = counts.ToDense(); // for inspectionBy default: lowercasing, token pattern
\b\w\w+\b(single-letter words are dropped — like sklearn). Configure viaCountVectorizerOptions.
Common settings (identical to sklearn):
var cv = new CountVectorizer(new CountVectorizerOptions
{
NgramRange = (1, 2), // unigrams + word bigrams
MinDf = 2, // drop rare terms (df < 2)
MaxDf = 0.9, // drop over-frequent terms (df > 90%)
StopWords = StopWords.English, // or any collection
Analyzer = AnalyzerKind.Char, // character n-grams
});Six lists ship: StopWords.English, .French, .German, .Italian,
.Portuguese, .Spanish — and any IReadOnlyCollection<string> works just as
well.
English is scikit-learn's 318-word list, for stop_words="english" parity. The
other five come from Snowball, not from nltk.corpus.stopwords: that corpus
has no stated licence, so it cannot be redistributed here
(decision 0010). Per-language
word counts against nltk's are in
docs/equivalence.md — but if you need exactly
what nltk removes, load the corpus yourself and pass it in.
Each list is built the first time it is read and never again, one language at a
time: a program that only ever asks for French does not pay for the other five.
On net10.0 a vectorizer handed one of the shipped lists reuses it as it is,
rather than copying it — a collection of your own is still copied, so that a
HashSet<string> you keep adding to cannot change what a vectorizer already
built removes.
Removal is an ordinal match against the analyzer's output, so a list only removes
what preprocessing leaves behind: with StripAccents = true, même becomes
meme and no longer matches. Single-letter entries (c, d, l, à in the
French list) never match under the default token pattern either, which drops
one-character tokens. scikit-learn behaves the same way in both cases.
The formula is scikit-learn's, to the character (a classic pitfall):
idf(t) = ln((1 + n) / (1 + df(t))) + 1, then L2 normalization of each row.
var tv = new TfidfVectorizer();
CsrMatrix tfidf = tv.FitTransform(docs);
IReadOnlyList<double> idf = tv.Idf; // the learned idf vectorOptions (TfidfOptions): UseIdf, SmoothIdf, SublinearTf, Norm (L1/L2/none).
No vocabulary (hence stateless, ideal for streaming). Uses MurmurHash3-32, identical to sklearn.
var hv = new HashingVectorizer(new HashingVectorizerOptions { NumFeatures = 1 << 18 });
CsrMatrix hashed = hv.Transform(docs); // no Fit neededCsrMatrix m = new TfidfVectorizer().FitTransform(["the cat eats", "the dog eats"]);
double[,] d = m.ToDense();
// dot product of the two rows (already L2-normalized) = cosine
double cos = 0;
for (int j = 0; j < m.ColumnCount; j++) cos += d[0, j] * d[1, j];
Console.WriteLine(cos); // ~0.51Fitting learns a vocabulary and, for TF-IDF, an idf vector. Both die with the process unless you write them down — so training on a corpus and scoring later, the normal split in any real pipeline, needs persistence.
var tfidf = new TfidfVectorizer().Fit(trainingDocuments);
tfidf.Save("model.json");
// …later, in another process
TfidfVectorizer reloaded = TfidfVectorizer.Load("model.json");
CsrMatrix scored = reloaded.Transform(newDocuments); // no refitThe equivalent of joblib.dump / joblib.load, with two differences that
matter. The artifact is versioned JSON, not a pickle — it is data, never
code, so it can be diffed, reviewed and read from a source you do not control.
And the round trip is bit-exact: scored is identical to what the original
vectorizer would have produced, element by element, not within a tolerance.
One part is not meant to be read: the idf vector is a base64 string of raw
IEEE-754 bits, because it is thirty thousand floats nobody inspects by eye and
writing it as JSON numbers was measurably the most expensive thing in the file.
The vocabulary, the options and the header stay plain text, which is where
diffing and review actually happen — docs/decisions/0011
has the measurements.
Save/Load also accept a Stream, and both have async counterparts. A stream
you pass in is never disposed for you; the path overloads own the file handle
they open.
CountVectorizer and HashingVectorizer persist the same way. Hashing is
stateless, but its options still round-trip — a pipeline reloaded with a
different NumFeatures or AlternateSign produces different columns for the
same document, and nothing downstream would notice.
Every count in an artifact sizes a buffer, so loading is bounded:
using Lodestar.Text.Persistence;
var strict = new ArtifactLoadOptions { MaxVocabularySize = 50_000, MaxTotalBytes = 8L * 1024 * 1024 };
TfidfVectorizer model = TfidfVectorizer.Load("model.json", strict);Anything the file gets wrong — a truncated document, an unknown property, an
unsupported version, a vocabulary that is not sorted, a limit exceeded — raises
InvalidDataException with a message naming the problem. The reasoning behind
the format is in
decision 0011.
See the equivalence table for the exact correspondence with each scikit-learn call.
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels