equivalence - CyrilB1531/lodestar GitHub Wiki

Python → C# equivalence table

Filled in as we go: a row is added at the same time as each function is implemented, never retrofitted at the end (§6.1 of the brief).

Lodestar.Text — distances & similarity

Python Library C# Differences
Levenshtein.distance(a, b) rapidfuzz Levenshtein.Distance(a, b) Compares UTF-16 units by default; pass TextElement.CodePoint for exact parity with Python on non-BMP characters (emoji…). Weights (1,1,1).
Levenshtein.normalized_distance(a, b) rapidfuzz Levenshtein.NormalizedDistance(a, b) distance / max(len(a), len(b)), 0 if both empty. Identical.
Levenshtein.normalized_similarity(a, b) rapidfuzz Levenshtein.NormalizedSimilarity(a, b) 1 - normalized_distance. Two empty strings ⇒ 1. Identical.
OSA.distance(a, b) rapidfuzz Osa.Distance(a, b) Optimal String Alignment (restricted Damerau): adjacent transposition allowed, no substring re-edited. Differs from full Damerau ("CA"/"ABC" ⇒ 3 vs 2). Not a metric: the triangle inequality fails.
OSA.normalized_similarity(a, b) rapidfuzz Osa.NormalizedSimilarity(a, b) 1 - dist/max(len). Identical.
DamerauLevenshtein.distance(a, b) rapidfuzz DamerauLevenshtein.Distance(a, b) Unrestricted Damerau (Lowrance-Wagner). "CA"/"ABC" ⇒ 2. At unit costs it is a true metric, unlike Osa, so it is the one to index with.
DamerauLevenshtein.normalized_similarity(a, b) rapidfuzz DamerauLevenshtein.NormalizedSimilarity(a, b) 1 - dist/max(len). Identical.
hamming_distance(a, b) jellyfish Hamming.Distance(a, b) Differing positions + length difference. Matches jellyfish on normal inputs; documented divergence on combining marks (decision 0007).
Indel.distance(a, b) rapidfuzz Indel.Distance(a, b) Insertions/deletions only = len(a)+len(b)-2·LCS. Basis of fuzz.ratio.
Indel.normalized_similarity(a, b) rapidfuzz Indel.NormalizedSimilarity(a, b) 1 - dist/(len(a)+len(b)). ×100 = fuzz.ratio.
jaro_similarity(a, b) jellyfish Jaro.Similarity(a, b) Empty ⇒ 0. Matches jellyfish except combining-mark quirks (decision 0007).
jaro_winkler_similarity(a, b) jellyfish JaroWinkler.Similarity(a, b) Prefix boost only when Jaro > 0.7 (Winkler threshold), weight 0.1, prefix ≤ 4.
SequenceMatcher(None,a,b).find_longest_match(...).size difflib Lcs.SubstringLength(a, b) Longest common (contiguous) substring. Same tie-break as difflib.
— (classic LCS) Lcs.SubsequenceLength(a, b) Longest common subsequence (order-preserving, non-contiguous). Basis of Indel.
SequenceMatcher(None,a,b).ratio() difflib RatcliffObershelp.Similarity(a, b) Gestalt 2·M/T. autojunk not replicated (identical for ≤ 200 elements; decision 0007).

Lodestar.Text — set similarity (q-gram multisets)

Python Library C# Differences
Jaccard(qval=1).normalized_similarity(a, b) textdistance Jaccard.Similarity(a, b) Multisets (bags) of q-grams, qval=1 by default. |A∩B|/|A∪B|.
Sorensen(qval=1).normalized_similarity(a, b) textdistance SorensenDice.Similarity(a, b) 2·|A∩B|/(|A|+|B|).
Overlap(qval=1).normalized_similarity(a, b) textdistance Overlap.Similarity(a, b) |A∩B|/min(|A|,|B|).
Tversky(qval=1).normalized_similarity(a, b) textdistance Tversky.Similarity(a, b) α=β=1 by default (⇒ Jaccard).
Cosine(qval=1).normalized_similarity(a, b) textdistance Cosine.Similarity(a, b) |A∩B|/√(|A|·|B|). Pass qval:2 for character bigrams.

textdistance raises on some empty inputs; Lodestar defines them cleanly: both empty ⇒ 1, one empty ⇒ 0. Inputs shorter than qval hold no gram: two of them give 1 when equal and 0 otherwise, where textdistance divides by zero. The one exception is a zero Tversky weight, which leaves the denominator empty as well — its entry says when. The oracle covers non-empty pairs (qval=1); edges are covered by unit tests.

Lodestar.Text — phonetic encoding

Python Library C# Differences
soundex(s) jellyfish Soundex.Encode(s) Initial letter + 3 digits. Exact parity (402 words).
metaphone(s) jellyfish Metaphone.Encode(s) Parity on real words; jellyfish non-word quirks not reproduced (decision 0005).
nysiis(s) jellyfish Nysiis.Encode(s) Non-truncated variant. Exact parity (402 words).
doublemetaphone(s) doublemetaphone DoubleMetaphone.Encode(s) The one row whose reference is not jellyfish, which exports no Double Metaphone — decision 0005 chose doublemetaphone 1.2 and has the comparison. Returns a DoubleMetaphoneCode pair; Secondary is "" where the reference repeats the primary, which is the convention the other encoders already use. Codes are not truncated. Exact parity (454 words, both codes).
match_rating_codex(s) jellyfish MatchRatingApproach.Codex(s) Unlike the three above, refuses (ArgumentException) a character that is neither a letter nor a space, instead of ignoring it — jellyfish does the same (ValueError). Exact parity (420 words).
match_rating_comparison(a, b) jellyfish MatchRatingApproach.Compare(a, b) bool?: null where jellyfish returns None (codices too far apart in length to rate). Length is measured in characters, not jellyfish's UTF-8 bytes — decision 0007 has the two cases this changes. Exact parity within that range (212 pairs).

Lodestar.Text — sparse vectorization

Python Library C# Differences
CountVectorizer() scikit-learn new CountVectorizer() Sorted vocabulary, token_pattern \b\w\w+\b (single characters dropped), lowercase by default. Parity across 13 configs.
CountVectorizer(ngram_range=(1,2)) scikit-learn new CountVectorizer(new(){ NgramRange=(1,2) }) Word n-grams joined by a space.
CountVectorizer(ngram_range=(0,n)) scikit-learn NgramRange = (0, n) Accepted, as scikit-learn accepts it: it validates only that the range ascends. A first length below 1 is a Python slice there, so it adds an empty-string term — the zero-length slice, taken at every position and so counted once per unit plus one — and a negative one counts back from the end. analyzer="word" at Max = 1 skips the slicing, so (0, 1) is (1, 1). Identical term for term on the oracle corpus; only (2, 1) is refused, on both sides.
CountVectorizer(analyzer="char"/"char_wb") scikit-learn Analyzer = AnalyzerKind.Char / CharWordBoundary Character n-grams (with/without crossing word boundaries). char rewrites only runs of two or more whitespace characters as one space, scikit-learn's \s\s+, so a lone tab or newline stays in its grams. Whitespace is Python's str.isspace, which counts U+001C to U+001F where char.IsWhiteSpace does not.
CountVectorizer(lowercase=True) scikit-learn Lowercase = true Simple case mapping, not Python's full mapping. ToLowerInvariant maps one UTF-16 unit to one: "ΟΔΟΣ" lowercases to οδοσ where str.lower gives οδος (final sigma), and İ (U+0130) stays İ where Python gives i followed by U+0307. ASCII and most scripts agree.
CountVectorizer(token_pattern=r"(?u)\b\w\w+\b") scikit-learn TokenPattern \w is .NET's class, not Python's. .NET counts combining marks as word characters and numbers that are not decimal digits (², ) as not; Python the reverse. Decomposed naïve is one token here and nai, ve in scikit-learn; नमस्ते is one token here and नमस there; x²y is one token there and none here. Precomposed Latin text agrees.
CountVectorizer(min_df=…, max_df=…) scikit-learn MinDf, MaxDf <1 = proportion, ≥1 = absolute count (sklearn _limit_features semantics). A fraction above 1, a negative value or NaN is refused, and so is a MaxDf that corresponds to fewer documents than MinDf, as scikit-learn refuses both.
CountVectorizer(strip_accents="unicode") scikit-learn StripAccents = true NFKD decomposition + removal of combining marks.
CountVectorizer(stop_words="english") scikit-learn StopWords = StopWords.English sklearn's 318-word list (identical). Any custom collection accepted.
nltk.corpus.stopwords.words("french") nltk StopWords.French Not identical. The shipped lists are Snowball's, not nltk's, for licensing reasons (decision 0002). Same for German, Portuguese, Spanish; Italian matches nltk word for word.
scipy.sparse (CSR) scipy CsrMatrix Home-grown CSR: ToDense, L1/L2 norms, NormalizeRows, matrix-vector product.
TfidfVectorizer() scikit-learn new TfidfVectorizer() smooth_idf + L2 normalization on by default. idf = ln((1+n)/(1+df)) + 1. Parity across 7 configs.
TfidfTransformer() scikit-learn new TfidfTransformer() use_idf, smooth_idf, sublinear_tf, norm (L1/L2/none).
HashingVectorizer() scikit-learn new HashingVectorizer() Hashing trick, no vocabulary. MurmurHash3-32 (seed 0) reproduced; alternate sign + L2 normalization by default.

Lodestar.Text — model persistence

Python Library C# Differences
joblib.dump(vec, path) / pickle.dump(vec, f) joblib / pickle vec.Save(path) / vec.Save(stream) Versioned JSON, not a pickle: data only, never code. Applies to CountVectorizer, TfidfVectorizer and HashingVectorizer. UTF-8 without BOM; the idf vector is base64-encoded raw IEEE-754 bits, the rest is readable JSON.
joblib.load(path) / pickle.load(f) joblib / pickle TfidfVectorizer.Load(path, options?) Static, not a constructor. Bounded by ArtifactLoadOptionspickle.load has no equivalent, since it trusts the file by design (decision 0001).
— (no equivalent) ArtifactLoadOptions Deliberate addition, not a port: caps vocabulary size, token length, JSON depth, total bytes and array length. Over a limit ⇒ InvalidDataException naming limit and value.

Lodestar.Text — stemming

Python Library C# Differences
PorterStemmer(mode=ORIGINAL_ALGORITHM).stem(w) nltk PorterStemmer.Stem(w) Porter (1980) algorithm, 5 steps. Exact parity (86 words).
SnowballStemmer("english").stem(w) nltk EnglishSnowballStemmer.Stem(w) Porter2: R1/R2 regions, exceptions. Exact parity (190 words).
snowballstemmer.stemmer("french").stemWord(w) snowballstemmer FrenchSnowballStemmer.Stem(w) French Snowball: elisions, RV region, 6 steps, NFC-normalized input. Exact parity with snowballstemmer 3.1.1: the 571-word corpus, all 346,244 words of /usr/share/dict/french, and 155,400 generated French-like words, with no difference (#973). nltk's SnowballStemmer("french") differs on 278 of the dictionary words: it has no elision, ë/ï, -oux, -aise or ni- rule, keeps the accent of a first letter (ès), and deletes an ic outside R2 after -atrice (indicatriceind, not indiqu); see 0006.
SnowballStemmer("spanish").stem(w) nltk SpanishSnowballStemmer.Stem(w) Spanish Snowball: attached-pronoun step 0, accents stripped last. Exact parity (127 words).
SnowballStemmer("portuguese").stem(w) nltk PortugueseSnowballStemmer.Stem(w) Portuguese Snowball: nasal a~/o~ expansion, accents kept. Exact parity (105 words).
SnowballStemmer("italian").stem(w) nltk ItalianSnowballStemmer.Stem(w) Italian Snowball: acute→grave folding, u/i marking. Exact parity (96 words); enzate follows nltk over the published text, see 0006.
SnowballStemmer("german").stem(w) nltk GermanSnowballStemmer.Stem(w) German Snowball: ßss, u/y marking, R1 floored at 3, no RV region. Exact parity (88 words).
SnowballStemmer("dutch").stem(w) nltk DutchSnowballStemmer.Stem(w) Dutch Snowball: umlauts and acutes folded first (è kept), y/i marking, R1 floored at 3 as in German, kk/dd/tt undoubled after a deletion, and a stressed aa/ee/oo/uu undoubled last. Exact parity (177 words).
SnowballStemmer("swedish").stem(w) nltk SwedishSnowballStemmer.Stem(w) Swedish Snowball: three steps, all in R1, with R1 floored at 3 as in German and no R2 or RV region. The region qualifies the search — the longest suffix inside R1, not the longest suffix tested against it — and ä/å/ö are neither marked nor folded. Exact parity (211 words).
SnowballStemmer("russian").stem(w) nltk RussianSnowballStemmer.Stem(w) Russian Snowball, the one Cyrillic alphabet: ё folded to е first, RV as the rest of the word after its first vowel, R2 for step 3 alone, and four ending tables tried in order. Parity over the 291 corpus words, with one shape excluded from it — a stem left ending in ъ keeps it, where nltk halves the letter and returns ь (0006, which also covers the ующая ending, where nltk is followed instead).
SnowballStemmer("danish").stem(w) nltk DanishSnowballStemmer.Stem(w) Danish Snowball: Swedish's three R1-only steps plus a fourth that undoubles a final bbtt, with step 3 repeating step 2 after a deletion and igst losing its st before the search runs. Exact parity (240 words); the published apostrophe rule is not implemented, following nltk, see 0006.
SnowballStemmer("norwegian").stem(w) nltk NorwegianSnowballStemmer.Stem(w) Norwegian Snowball, Bokmål — nltk's norwegian is the Bokmål algorithm, and Nynorsk is out of scope for the same reason Turkish is. Three steps, all in R1, with R1 floored at 3 as in German and no R2 or RV region; the region qualifies the search, as in Swedish. ert/erte are rewritten to er rather than stripped, a bare s needs one of eighteen letters or a k not itself after a vowel before it, and æ/å/ø are neither marked nor folded. Exact parity (244 words).
SnowballStemmer("finnish").stem(w) nltk FinnishSnowballStemmer.Stem(w) Finnish Snowball, the longest of the twelve: six steps over standard R1 and R2, peeling particles, possessives, cases, comparatives and plurals in that order, with vowel harmony choosing between the back and front spelling of each ending and a tidying step that undoubles a vowel, a consonant and a consonant pair. Exact parity (217 words); a failed condition ends the search except on the four long spellings of the genitive, following nltk, see 0006.
snowballstemmer.stemmer("hungarian").stemWord(w) snowballstemmer HungarianSnowballStemmer.Stem(w) Hungarian Snowball: nine steps, all in R1, no R2 or RV region. R1 opens after the first consonant when the word starts with a vowel and after the first vowel otherwise, with a digraph counting as one consonant; the instrumental and factive steps undouble the consonant the ending doubled. One of the three rows here not oracled by nltk, beside Arabic and French — nltk 3.10.3's Hungarian omits ő and ű from its vowel set and től/ről/ből from step 2, so it leaves nők, szőlők, gyűrűk and every -ből form unstemmed (decision 0006). Exact parity (211 words).
snowballstemmer.stemmer("arabic").stemWord(w) snowballstemmer ArabicSnowballStemmer.Stem(w) Arabic Snowball, the one right-to-left script: vocalisation marks, kasheeda and lam-alef ligatures resolved first and the Arabic-Indic digits folded to ASCII, then suffixes and prefixes from both ends, with the hamza carriers resolved last. It uses neither R1 nor R2, so it is the one stemmer outside SnowballWorkerBase. Exact parity (146 words); oracled by snowballstemmer rather than nltk, whose Arabic is not a pure function, see 0006.
SnowballStemmer("romanian").stem(w) nltk RomanianSnowballStemmer.Stem(w) Romanian Snowball, the one Romance language of the later nine and so the one built on the same RV as Spanish, Portuguese and Italian: five steps, of which step 1 loops and only step 3 lets an ending rejected by its region fall through to a shorter one; i/u between vowels marked first. The suffix tables carry the cedilla ş/ţ the description writes, so a word spelled with the modern ș/ț matches only the endings without them — informaţie reaches inform, informație reaches informaț. Exact parity (349 words); two shapes no Romanian word reaches read the regions off the word rather than off nltk's stale copies, see 0006.

Lodestar.Embeddings — sub-word tokenization & pooling

Python Library C# Differences
Tokenizer(WordPiece(vocab)).encode(t) tokenizers (HF) new WordPieceTokenizer(vocab).Encode(t) Greedy longest match, ## continuation, [UNK]. Pre-tokenization \w+|[^\w\s]+ as Oniguruma reads it, over code points: a word character is a letter, a mark, Nd, Nl, Pc, ZWNJ/ZWJ or one of the circled and squared Latin letters, whitespace is White_Space. Exact parity, swept over all 1,112,064 Unicode scalar values between two as against tokenizers 0.23.2 on .NET 10 — issue #887, whose spec has why this is not a .NET regex. The added_tokens table is matched as text, ahead of the pre-tokenizer, rather than folded into the vocabulary as whole-word entries — with lstrip, rstrip and single_word honoured, and the same AddedTokenScanner BpeTokenizer uses, so a flag cannot mean two things. Which text an entry is matched against is decided by its normalized field and not by special: non-normalized entries run in an outer pass over the raw input and emit the raw slice, normalized ones have their own content normalized and run over the lowercased gaps the first pass left. A special-but-normalized entry is lowercased like any other — the added-token flag rules. WordPieceVocabulary.Count counts Vocab alone and therefore under-counts what Encode can emit, as BpeVocabulary already did. With WordPieceVocabulary.BasicTokenization set, as VocabTxtLoader sets it, BERT's BasicTokenizer replaces this pre-tokenization; the BertTokenizer loader row has its parity.
Tokenizer(Unigram(...)).encode(t) / sp.encode(t) tokenizers / sentencepiece new SentencePieceTokenizer(vocab).Encode(t) Unigram via Viterbi (max log-probability), preceded by the model's own precompiled_charsmap and its whitespace flags. Exact parity over four vocabularies and four different character maps — stock XLM-R's nmt_nfkc (the map every stock T5, ALBERT and camemBERT also carries, byte for byte), a nmt_nfkc_cf model, a hand-written three-rule map, and tiny_sp.model, which has none. remove_extra_whitespaces collapses runs of U+0020 only; a run of uncovered characters comes back as one unknown piece, as in Python.
sp.normalize(t) sentencepiece vocab.Normalizer.Normalize(t) The precompiled_charsmap alone: a longest-match walk over a darts-clone trie. Covers every built-in rule and any --normalization_rule_tsv, because they all compile to that one blob. Not reimplemented on string.Normalize(FormKC), which would drift: the map is frozen at the Unicode version that compiled it and the two already differ on 181 code points — decision 0005.
sp.encode(t) over the XLM-R vocabulary sentencepiece new SentencePieceTokenizer(SentencePieceModelLoader.Load("xlmr_fairseq.model")).Encode(t) 250 002 pieces with <s>=0, <pad>=1, </s>=2, <unk>=3, <mask>=250001 — the layout HuggingFace gives XLM-R, and the one an id-based control filter gets wrong. Identical segmentation over Latin, Cyrillic and Japanese input, including text naming all five markers literally: none is ever matched as text. Fixture built by tools/fetch_xlmr_vocab.py.
sp.encode(t) with an all-positive-score vocabulary sentencepiece idem The unknown piece is scored min(0, min_score) - 10 where Python uses min_score - 10. Identical for every real model (scores are log-probabilities, so the floor never binds); Lodestar penalises the unknown piece more where it does. Decision 0005.
Tokenizer(BPE(vocab, merges)).encode(t) with a ByteLevel pre-tokenizer tokenizers (HF) new BpeTokenizer(vocab).Encode(t) Lowest-ranked-merge-first, over a doubly-linked list of symbols and a priority queue rather than a rescan-and-shift loop — see the scaling figures in decision 0005. Added-token matching before merging — the whole added_tokens table, including the special tokens model.vocab also declares, with each entry's lstrip, rstrip and single_word flags honoured as measured; the raw-versus-normalized pass that flag table also carries normalizes each gap between raw added tokens in isolation and matches the normalized half of the table inside that gap, raw entries against raw text and normalized entries against normalized text — ignore_merges, and add_prefix_space, which is applied per added-token-delimited segment and only where the segment does not already begin with a space, as ByteLevel does in Python. End-to-end parity over GPT-2's vendored 50 257-entry vocabulary and merge table (byte-level) and a self-trained model (the classic, non-byte-level lineage); BpePatterns.Llama3 and BpePatterns.Qwen2 are proven at the split level only, against the vocabulary the caller supplies — decision 0005 again. A byte-level model missing one of the 256 alphabet characters from model.vocab while declaring it as an added token now throws ArgumentException from ByteLevelSymbols rather than silently folding the added token's id in, as it did before issue #130; the reference does neither, dropping the uncovered byte instead since there is no unk_token to substitute — measured, aQa with byte Q missing from model.vocab and present only as an added token is ['a', 'a'] there — so this swaps one divergence for another, throwing rather than returning a wrong token stream.
BPE(..., fuse_unk=True) tokenizers new BpeVocabulary(vocab, merges) { FuseUnk = true } A run of consecutive uncovered characters is one unknown token, not one each. The run stops at a pre-tokenizer boundary, so "aZ Za" under Whitespace keeps two. Fusing happens before merging, so a fused symbol can take part in a merge. With no UnkToken the flag does nothing, because an uncovered character is dropped rather than substituted; on a byte-level model it does nothing either, because all 256 characters are covered.
tokenizer.decode(ids) tokenizers (HF) BpeTokenizer.Decode(ids) Byte-level: every complete, well-formed UTF-8 byte sequence round-trips exactly, including malformed-looking sequences that came from Encode. A byte sequence that is not well-formed UTF-8 — a token split across a multi-byte character's boundary, decoded on its own, is the ordinary way this happens — substitutes U+FFFD, matching HuggingFace rather than throwing, decision 0007. Decode_of_one_id_at_a_time_matches_the_reference proves it against tokenizers 0.23.1's own per-id output over CJK, emoji and accented text, #149. skipSpecialTokens defaults to false — the opposite of Python's skip_special_tokens=True — so Decode(Encode(x)) == x holds without passing an extra argument; pass true to drop added tokens. It drops exactly the added tokens whose added_tokens entry is special, carried on AddedToken.Special, matching Python's skip_special_tokens. Proven over the byte-level and classic corpora above, decode direction, including a GPT-2 corpus whose text names <|endoftext|> at the id model.vocab gives it. An added token carrying lstrip (or rstrip) breaks the byte-exact round trip, here and in Python alike: the absorbed whitespace is consumed into the match and is not restored, so 'a <mask> b' decodes to 'a<mask> b'. Following HuggingFace is the parity; restoring the space would be the divergence — the added-token flag rules. With byte_fallback declared the decoder block is reproduced for the first time on this lineage, in the two shapes TokenizerJsonLoader.LoadBpe reads: a bare ByteFallback step undoes only the byte pieces, back into their UTF-8 bytes, and leaves any whitespace escape in place, where Llama-2's own chain — Sequence[Replace, ByteFallback, Fuse, Strip] — undoes the escape and the byte pieces together, in that order. The two are a genuine contrast, not two texts that each merely round-trip themselves: bpe_byte_fallback.json's decoder_byte_fallback and decoder_sequence cases share one vocabulary and one Metaspace pre_tokenizer, so they encode every text to identical ids, and only the declared decoder differs — on those same ids, decoder_byte_fallback decodes "aéb" to "▁aéb", the escape left in, and decoder_sequence decodes it to "aéb", matching tokenizers 0.23.1 exactly on both. Any other decoder shape on a byte_fallback file is refused rather than reduced, matching what the loader already refuses. A run of byte pieces that is not well-formed UTF-8 — a generation truncated mid-character is the ordinary way one arises — decodes to one U+FFFD per byte of the run, which is HuggingFace's own ByteFallback rule and not the per-maximal-invalid-subpart substitution the byte-level path shares with Rust's from_utf8_lossy: <0xF0> <0x9F> is two replacement characters and <0xC3> <0x28> two as well, where a lossy decoder answers one, and one plus (. bpe_byte_fallback.json's decode_runs column replays the raw ids, which Encode structurally cannot produce.
tokenizer.add_tokens([...]) tokenizers BpeVocabulary.AddedTokens An added token is matched as literal text and carries an id, but it is not a model vocabulary entry: a character it spells that model.vocab does not declare is still substituted with the unknown token. Measured, aQa with Q an added token absent from model.vocab and single_word on is ['a', '[UNK]', 'a']. TryGetId and Decode still see it, matching token_to_id and decode. Which text an entry is matched against comes from normalized, and what its content is normalized with comes from how the file spelled the whitespace escape: tokenizers runs the declared normalizer over a normalized: true entry's content and never a pre-tokenizer, so Llama-2 — whose escape is a Prepend+Replace normalizer — matches on ▁<s> and therefore not after a letter, where the model spells <, s, > instead; Mistral v0.1 declares its entries raw and matches <s> anywhere. docs/equivalence.md's added-token rows has the sixteen rows measured. A Metaspace pre-tokenizer with a normalized: true entry is refused at construction: the escape would depend on the piece's position, which a fixed pattern cannot carry.
— (refused) tokenizers new BpeTokenizer(…) throws ArgumentException Two shapes the reference also refuses while reading the document: a merge naming a token model.vocab does not declare — measured, Token `Q` out of vocabulary — and a merge whose result is absent, refused here too but with a message of Lodestar's own, since the reference panics there instead of raising: range end index 2 out of range for slice of length 1. A third shape, an unk_token present only in added_tokens, the reference does not refuse to build: it loads the file, answers token_to_id, and encodes text the model already covers, raising only from encode and only on text needing a substitution the vocabulary cannot supply. Lodestar refuses it here, at construction — earlier than the reference, a divergence in timing rather than outcome.
mean pooling + F.normalize sentence-transformers Pooler.MeanPoolAndNormalize(...) Masked mean (padding excluded) + L2 normalization.
util.semantic_search / corpus @ query sentence-transformers / numpy new EmbeddingIndex(dim).Search(q, k) Exhaustive SIMD-vectorized cosine. Top-k, index-ascending tie-break.
mean pooling over a [batch, seq, dim] tensor sentence-transformers Pooler.MeanPoolBatch(...) / MeanPoolAndNormalizeBatch(...) Each row pooled against its own slice of the mask. Vectorized with Vector<float> on net10.0, scalar on netstandard2.0, and the two are bit-identical — asserted with float equality, not a tolerance, because one frozen corpus serves both builds.
tokenizer(texts, padding=True, truncation=True, max_length=n) tokenizers (HF) new BatchEncoder(tokenizer, options).EncodeBatch(texts) Inserts the template's special tokens, truncates inside a budget that counts them as HuggingFace does, pads each batch to its own longest row (padding="longest", never "max_length") and builds the attention mask. Ids and mask replayed against encode_batch for equality, not within a tolerance. TruncationStrategy.None refuses an over-long text; HuggingFace's truncation=False returns it untruncated.
[tokenizer(t).ids for t in texts] tokenizers (HF) new BatchEncoder(tokenizer, options).EncodeAll(texts) The same encoding as EncodeBatch, stopped before the rectangle: one row per text, each already carrying its template tokens and already truncated, none padded. No HuggingFace call returns exactly this — padding=False comes back wrapped in an Encoding — so the counterpart is the comprehension above. Exercised through the same frozen corpus, since EncodeBatch is this call followed by Pad.
tokenizer.pad(encodings) tokenizers (HF) new BatchEncoder(tokenizer, options).Pad(sequences, start, count, order) Lays a window of already-encoded rows out as one rectangle, padded to the longest row in that window. HuggingFace's pad takes the whole list and has no window: start, count and order are what let a caller group rows by length before paying for the widest one in each group. Divergence: the empty window still gets one masked-off column, because an inference runtime refuses a zero-width tensor.
TemplateProcessing(single="[CLS] $A [SEP]") tokenizers (HF) SpecialTokenTemplate.Bert / .Roberta / .T5 / .None The wrapping as data. Tokens are named, never numbered — the id comes from the model's vocabulary through ISubwordTokenizer.TryGetId, so a vocabulary placing [CLS] anywhere works and one lacking it throws at construction. A pair template ($A/$B) is not supported: Lodestar encodes one sequence at a time.
onnxruntime.InferenceSession(...).run(...) + pooling onnxruntime new OnnxTextEmbedder(path).Embed(ids, mask) (Lodestar.Onnx) Loads an ONNX model (weights not redistributed), runs it, mean-pool + L2. Feeds token_type_ids only if the model declares it. Takes ReadOnlySpan<long> since 0.3.0, where it took IReadOnlyList<long>. Refuses an output whose rank is neither 3 nor 2, and any input or output name the model does not declare.
SentenceTransformer.encode(texts, batch_size=n, normalize_embeddings=True) sentence-transformers new OnnxTextEmbedder(path, tokenizer).EmbedBatch(texts, options) (Lodestar.Onnx) The whole chain in one call: encode, sub-batch, pad, run, mean-pool, normalize, restore the caller's order. SortByLength buckets by length between sub-batches and changes nothing observable. Agreement with a float64 reference is bounded near 1e-7, not 1e-9: ONNX Runtime returns float32 and the vector is normalized in float32. convert_to_tensor, show_progress_bar and the pooling modes other than mean have no equivalent.

Lodestar.Embeddings — vocabulary loaders

Python Library C# Differences
BertTokenizer(vocab_file=…) vocabulary loading transformers VocabTxtLoader.Load(path, …) One token per line, id = line number. Reproduces two quirks of the Python loop: a blank line is a token whose string is empty, and a repeated token keeps the last id. A UTF-8 BOM is stripped rather than absorbed into the first token. The tokenizer built from the result runs BERT's BasicTokenizer, BertNormalizer(clean_text, handle_chinese_chars, strip_accents=None, lowercase) then BertPreTokenizer, and is identical to tokenizers over a cased and an uncased corpus of punctuation runs, accents, CJK, control and format characters and unassigned code points, which it keeps. One divergence, from the Unicode tables rather than the algorithm, measured by the whole-code-point sweeps of #992 and #983 over this pipeline: about 600 assigned code points are classified differently by .NET 10 and by tokenizers' own tables, so 455 nonspacing marks are stripped here and kept there, 20 format characters dropped here and kept there, and about 140 punctuation characters split a word here and not there. Four go the other way, the same skew read backwards: U+1734 and U+1171E are Mc to .NET 10 and a nonspacing mark to tokenizers, which strips them, and U+166D (So here) and U+111C9 (Mn here) are punctuation to tokenizers, which cuts a word at them. The Whitespace pre-tokenizer's own sweep found no residue at all (#887), which is what the rows above and below still claim for it; this row's residue is not that one. A word is capped at 100 code points, as max_input_chars_per_word counts them (decision 0005).
Tokenizer.from_file("tokenizer.json") (WordPiece) tokenizers (HF) TokenizerJsonLoader.LoadWordPiece(path) Reads model.vocab, unk_token, continuing_subword_prefix, and derives lowercase from the normalizer. The whole added_tokens table lands in WordPieceVocabulary.AddedTokens with all five flags — lstrip, rstrip, single_word, special, normalized — instead of being folded into model.vocab: a folded entry is an ordinary whole-word vocabulary member and cannot honour a flag. normalized absent falls to !special, which is Rust's AddedToken::from default rather than a measured behaviour — tokenizers refuses a file omitting the field, so no corpus can reach that path. Refuses a pipeline it does not reproduce — NFKC/Precompiled normalizers, a non-Whitespace pre-tokenizer, any post_processor, truncation or padding — rather than ignoring it, and refuses an unk_token that model.vocab does not define even when added_tokens does: the table is matched as text ahead of the model, so an unknown token declared only there is one the model can never fall back to.
Tokenizer.from_file("tokenizer.json") (Unigram) tokenizers (HF) TokenizerJsonLoader.LoadUnigram(path) Reads the [piece, score] pairs and unk_id. tokenizer.json records no piece types, so they are derived: the special entries of added_tokens become Control, the piece at unk_id becomes Unknown. A Precompiled normalizer is read — it is the same blob a spiece.model carries, base64-encoded — through the same interpreter, so the two formats describe the same model identically. NFKC is still refused: it asks for the runtime's Unicode tables where the model asked for a frozen map. Pre-tokenizer must be Metaspace with .
models.BPE.from_file(vocab, merges) tokenizers (HF) BpeFilesLoader.Load(vocabPath, mergesPath) Reads the pre-tokenizer.json vocab.json + merges.txt pair GPT-2 (and Llama-3, Qwen2) ship. Neither file carries a pipeline, so byteLevel (default true, GPT-2's own default) and the split pattern (BpePatterns.Gpt2 when byte-level, BpePatterns.Whitespace when not) are parameters, not read from the files — the second is named on the vocabulary now that leaving it unset no longer means the classic split. Proven over GPT-2's vendored vocab.json/merges.txtdecision 0005.
Tokenizer.from_file("tokenizer.json") (BPE) tokenizers (HF) TokenizerJsonLoader.LoadBpe(path) Reads model.vocab/model.merges, the whole added_tokens table (the entries model.vocab also declares included — that is where every special token is — together with all five of each entry's flags — lstrip, rstrip, single_word, special and normalized), ignore_merges, end_of_word_suffix, unk_token, fuse_unk, byte_fallback, a normalizer of NFC, NFKC, NFD, NFKD or a Sequence of those — empty included, which normalizes nothing — applied in the order declared, and derives byte-level-ness and the split pattern from pre_tokenizer — a bare ByteLevel (stock GPT-2), Whitespace (classic lineage), or a Sequence of Split then ByteLevel (the Llama-3/Qwen2 shape), or no split at all — which an absent pre_tokenizer and a bare ByteLevel with use_regex off both mean, read as BpeVocabulary.NoPreTokenizer (row below). Reads the whitespace escape of the SentencePiece-BPE lineage in either of the two spellings a file uses for it — a Metaspace pre-tokenizer whose split is off, and a normalizer Sequence of Prepend then Replace — docs/equivalence.md's loader rows §2 as docs/equivalence.md's Metaspace rows amends it, and the two rows below. The two are one transform but for the prepend, where they are two: the transform carries which declaration it was read from, and BpeTokenizer tells it which piece it is escaping. A file writing both is refused naming both: neither model 0050 was read from does, so nothing measured says which one such a file means. A vocabulary declaring byte_fallback must carry all 256 <0xXX> pieces — uppercase hexadecimal, one per UTF-8 byte value — or the load is refused naming the first one missing: tokenizers itself accepts a partial alphabet and degrades silently, per symbol, to the unknown token, or, with unk_token: null, drops the symbol outright and lets its neighbours merge across the hole — neither is a stream this refusal lets through (decision 0007) §3 left open). Reads a TemplateProcessing post_processor's single template into BpeVocabulary.PrefixTokens and BpeVocabulary.SuffixTokens — the special tokens before and after the sequence, ["<s>"] and empty for Llama-2 and Mistral v0.1 — and reads and discards pair, with type_id alongside it: nothing here encodes a pair, and pair is not a stable property of a model — daryl149/llama-2-7b-chat-hf writes [<s>, A, B] where TheBloke/Llama-2-7B-fp16 writes [<s>, A, <s>, B], on two mirrors agreeing byte for byte on vocabulary, merges, normalizer, pre-tokenizer, decoder and single. The pad token is not read: it lives in enable_padding, which stays refused, so a caller builds new SpecialTokenTemplate(vocabulary.PrefixTokens, vocabulary.SuffixTokens, padToken) with the one value the file does not state. LoadWordPiece and LoadUnigram still refuse any post_processor outright. The decoder block is read strictly, but only when byte_fallback is declared: a bare {"type": "ByteFallback"}, or a Sequence of exactly [Replace, ByteFallback, Fuse, Strip] in that order — the chain Llama-2 declares — with any other length, order or step type refused by name; a file declaring no byte_fallback keeps the looser, unapplied Metaspace reading the row below states. Refuses, naming what it found: a non-zero dropout — a training-time regularizer; set it to null to load the file, and see decision 0005, a non-empty continuing_subword_prefix on a byte-level pre-tokenizer, a normalizer other than those four forms or Sequence — a bare Replace by name, since its pattern may be a Rust regex whose flavour .NET does not share, and anything else by name too — a ByteLevel block declaring no add_prefix_space in any of the three positions one can appear in — top-level pre_tokenizer, a Sequence step, the decoder — since tokenizers has no default for that field and refuses the file itself, truncation, padding, a post_processor that is not TemplateProcessingRobertaProcessing and ByteLevel among them — or a TemplateProcessing whose single template names no sequence, names more than one, names B rather than A, or holds a step that is neither a SpecialToken nor a Sequence, any other pre-tokenizer shape — a Metaspace whose split is on among them — and a decoder whose byte-level-ness disagrees with the model's own — which would not decode what it encodes, in Python either. Accepts the values that provably change nothing: a dropout of 0.0 and an end_of_word_suffix of "", which reads back as absent since an empty marker marks nothing — bpe_no_op_settings.json replays tokenizers producing the same tokens with each of the two as without it. An omitted use_regex and an omitted trim_offsets are accepted too: the first has a default in the reference, the second is never read here. All four forms agree between String.Normalize and tokenizers over unicode_forms.json's 56 cases. With a normalizer declared, Decode(Encode(x)) returns the normalized text rather than x, exactly as in Python — bpe_normalizer.json measures it, U+FFFD substitution included. Proven over all three pipeline shapes: byte-level GPT-2, the classic lineage, and a Sequence pattern shaped like Qwen2's — split-level only for Llama-3/Qwen2 themselves, decision 0005. Unrecognized top-level properties — the file's own version included — are accepted in silence here and by LoadWordPiece/LoadUnigram alike, where an artifact Lodestar itself wrote would reject them: tokenizers gains fields between releases, and refusing every one would refuse files that tokenize identically; what is checked is only the set of sections that change tokenization.
Sequence([Split(pattern), ByteLevel(use_regex=True)]) tokenizers new BpeVocabulary(vocab, merges) { ByteLevel = true, PreSplit = new BpeSplitStep(pattern, SplitBehavior.Isolated, Invert: false), PreTokenizerPattern = BpePatterns.Gpt2 } Both patterns apply, in order: the Split step's produces the pieces and ByteLevel's own re-splits each of them. With use_regex: false the second is absent and the Split pattern is the only one. The difference is not cosmetic — GPT-2's pattern knows only the contractions 's, 't, 're, 've, 'm, 'll, 'd, so aujourd'hui is three pieces with the second split and two without it, while don't is the same either way. All five behavior values and invert are reproduced now — see the Split row below — and the loader refuses a Split step declaring an absent or unknown one, as tokenizers 0.23.1 does. add_prefix_space goes on every piece the Split step produces, unless the piece already begins with a space, which is where HuggingFace puts it too — once per piece handed to the ByteLevel step, not once per input. tests/oracles/bpe_prefix_space.json measures it over 35 cases across five models.
Split(pattern, behavior=…, invert=…) tokenizers new BpeVocabulary(vocab, merges) { PreSplit = new BpeSplitStep(pattern, SplitBehavior.Isolated, Invert: false) } All five behaviours and both invert values. Isolated keeps every match and every gap, Removed keeps the gaps alone, the two merge directions attach each match to the gap before or after it, and Contiguous is Isolated with adjacent matches joined — so Split("X", Contiguous) over "aXXb" gives ['a','XX','b'] where Isolated gives ['a','X','X','b']. invert swaps the roles of match and gap, which makes it a no-op for Isolated and Contiguous and exchanges the two merge directions. Empty pieces are dropped. behavior and invert are required fields: a file omitting either is refused here and by tokenizers 0.23.1, and the behaviour is read in the file's PascalCase spelling, not the Python constructor's snake_case. The pattern itself has two spellings and both are read: pattern.Regex, and pattern.String — the literal pre_tokenizers.Split("|", "isolated") writes, which being the shortest correct call is the likelier file to meet. A literal is escaped rather than interpreted, and \d is where that shows: Split(String "\d") cuts at a backslash followed by a d and leaves a digit alone, where Split(Regex "\d") cuts at every digit. A pattern node declaring both keys, or neither, is refused naming both — tokenizers writes exactly one, so neither shape is a file it produces, and the both case is refused on the two keys being present rather than on both values being readable. tests/oracles/bpe_split_literal.json measures 36 cases over 12 models, each literal beside its escaped twin. The escape is Regex.Escape, which does not produce the same string as Python's re.escape: measured, it leaves -, ], }, ~, & and the vertical tab bare where re.escape backslashes them, and spells tab, newline, carriage return and form feed as \t, \n, \r and \f where re.escape backslashes the character itself. Each output still matches the same literal under its own engine, so the reproduction holds — but for those characters it holds on .NET's documented escaping rather than on this corpus, whose six literals (\d, a.c, |, ab, an astral emoji and the empty string) carry none of them. In a Sequence, behavior/invert govern this step only — a following ByteLevel step (BpeVocabulary.PreTokenizerPattern) re-splits every piece this one produces, always Isolated with invert off, since the format gives ByteLevel no behavior field of its own.
pre_tokenizers.Whitespace() tokenizers new BpeVocabulary(vocab, merges) { PreTokenizerPattern = BpePatterns.Whitespace } The classic, non-byte-level lineage's own split — word runs, punctuation isolated — matched by the same code-point scanner as WordPieceTokenizer's row, not by a .NET regex, wherever that exact pattern string is declared: .NET's \w leaves out spacing and enclosing marks, letter numbers and ZWJ/ZWNJ, and sees an astral letter as two surrogates (issue #887). BpePatterns.Whitespace is the pattern BpeTokenizer used to supply when a vocabulary named none, and naming it is now how a caller asks for it: a vocabulary declaring no PreSplit, no PreTokenizerPattern and no NoPreTokenizer is refused by the constructor rather than given this one, since that same shape is what a model splitting nothing at all would look like. TokenizerJsonLoader.LoadBpe sets it for a Whitespace pre-tokenizer, BpeFilesLoader.Load for a non-byte-level vocab.json/merges.txt pair. Measured, "aZ Za" is four tokens under it — ['a', '[UNK]', '[UNK]', 'a'] — where the same vocabulary with no split gives three (bpe_no_split.json, models whitespace and absent).
pre_tokenizer: null / ByteLevel(add_prefix_space=…, use_regex=False) tokenizers new BpeVocabulary(vocab, merges) { NoPreTokenizer = true } Nothing is split: the text reaches the merge loop whole, so a merge may span what a pattern would have cut at. Two file shapes mean it and TokenizerJsonLoader.LoadBpe reads both — a tokenizer.json declaring no pre_tokenizer at all, and a bare ByteLevel whose use_regex is off — with ByteLevel following the shape, off for the first and on for the second. "Whole" is per added-token segment, not per text: with <sep> added, "o o<sep>o o" is ['oĠ', 'o', '<sep>', 'oĠ', 'o'] where the same model with use_regex on gives ['o', 'Ġ', 'o', '<sep>', 'o', 'Ġ', 'o'] — the merge spans the space the pattern cuts at, and the added token still ends the segment. add_prefix_space applies once, to the segment rather than to each piece: "hello world" gains a single leading Ġ and decodes to " hello world", while a text already beginning with a space gains nothing. Decode(Encode(x)) returns x on the byte-level shape, leading and trailing spaces included. Declaring it beside PreSplit or PreTokenizerPattern is refused, the two being contradictory. 22 cases over 7 models in bpe_no_split.json, against tokenizers 0.23.1.
pre_tokenizers.Metaspace(replacement="▁", prepend_scheme=…, split=False) tokenizers TokenizerJsonLoader.LoadBpe(path), applied inside BpeTokenizer.Encode(text) Mistral v0.1's spelling of the whitespace escape, and the first of the two docs/equivalence.md's loader rows §2 makes one internal value: every " " becomes the replacement and the replacement is prepended, and nothing is split. It runs after the normalizer and once per added-token gap, so an added token's own content is normalized but never escaped — the file did not spell that entry with the symbol. replacement defaults to and must be a single character. All three prepend_scheme values are read; with the field absent the pre-0.14 add_prefix_space stands in for it, true reading as always, and both fields absent leaving always — which is tokenizers' own default, and an explicit prepend_scheme wins over add_prefix_space in both, measured by bpe_metaspace.json's legacy_add_prefix_space and legacy_add_prefix_space_beside_prepend_scheme. Two things about the prepend are reproduced, and they are what the normalizer spelling below does not share. It is guarded: skipped when the escaped text already begins with the replacement, and the guard reads the text after the replace, so a leading space meets it as much as a leading symbol — " the cat" and "▁the cat" are both ['▁the', '▁cat'] under first and always alike, where the normalizer gives ['▁', '▁the', '▁cat']. And first means the opening piece, not the whole text: an added token is a piece and spends it, so "<s>the cat" is ['<s>', 'the', '▁cat'] under first and ['<s>', '▁the', '▁cat'] under always. Both models declare special tokens, so this is the ordinary prompt rather than an edge of it. never has no prepend to guard or to place. One divergence, on the reading rather than the transform: add_prefix_space: false with no prepend_scheme is read here as never, where tokenizers 0.23.1 refuses the file outright (add_prefix_space does not match declared prepend_scheme) — so no oracle case can carry that shape, and BpeMetaspaceLoaderTests pins it instead. Refuses a Metaspace whose split is on, naming split: BpeTokenizer has no pattern for Metaspace's own segmentation, decision 0005 §3. The decode side is not reproduced: a Metaspace decoder block is accepted — it is not byte-level, so it does not contradict the model — and not applied, so Decode returns the escaped text, symbols and all. The corpus's fixtures declare no decoder for that reason and carry no decoded column. Proven on the real file since #318: sentencepiece_bpe_lineage.json replays mistralai/Mistral-7B-v0.1's own tokenizer.json — 32 000 entries, 58 980 merges, this Metaspace over byte_fallback — on eight texts beside Llama-2's eight, matching tokenizers 0.23.1 on tokens, ids and Decode, with the emoji rows reaching the byte alphabet rather than stopping at the merge loop. The file is vendored by tools/fetch_llama2_mistral_tokenizers.py against a pinned SHA-256 and a second mirror.
normalizers.Sequence([Prepend("▁"), Replace(" ", "▁")]) tokenizers TokenizerJsonLoader.LoadBpe(path), applied inside BpeTokenizer.Encode(text) Llama-2's spelling of the same escape, written in the normalizer with a null pre_tokenizer, and reproduced exactly: bpe_metaspace.json's prepend_replace_normalizer matches over all nine texts. A normalizer runs once over the whole text, so it prepends once, which is prepend_scheme first with nothing to split. The two steps have to agree — Prepend's string is the replacement, and Replace must map the literal " " onto that same string through pattern.String; a pattern.Regex keeps the refusal a bare Replace has always had, its flavour being Rust's. Refuses a Sequence that names Prepend or Replace and is not exactly those two in that order — three steps, refuse — naming Sequence, since reducing it would leave the third step silently unrun; a Sequence naming neither reaches the Unicode-form reader unchanged. It reads as always, not first: a normalizer is not a pre-tokenizer and counts no pieces, so it runs on every gap the added tokens leave and prepends to each — measured, "<s>the cat" is ['<s>', '▁the', '▁cat'] here and ['<s>', 'the', '▁cat'] under Metaspace{first}. It prepends unguarded too, Prepend running before Replace and so never seeing the symbol a leading space is about to become. Those two are the whole of what the spellings do not share, and they are docs/equivalence.md's Metaspace rows, which bounds the equality 0050 §2 rests on; the corpus cross-checks both between its own cases, with no Lodestar type involved. Proven on the real file since #318: sentencepiece_bpe_lineage.json replays Llama-2's own tokenizer.json — 32 000 entries, 61 249 merges, this normalizer over byte_fallback — on eight texts beside Mistral's eight, matching tokenizers 0.23.1 on tokens, ids and Decode. The one text the two models answer differently is "😀ok", a whole token to Mistral and four byte pieces here, which is what keeps the corpus from measuring one thing twice. The file is vendored under decision 0002's named exception to 0003's allowed-source list, from TheBloke/Llama-2-7B-fp16 against a pinned SHA-256 and a second mirror.
BPE(..., continuing_subword_prefix="##") tokenizers new BpeVocabulary(vocab, merges) { ContinuingSubwordPrefix = "##" } On the classic, non-byte-level lineage: every symbol after the first of each pre-tokenized piece is looked up prefixed, so "ab ab" gives ['a', '##b', 'a', '##b'] — the second word starts bare. There is no fallback: a non-initial symbol whose prefixed form is absent is substituted or dropped like any uncovered character, and the bare form is not consulted, which is what the reference does. A merge's result is its left side plus its right side without the prefix, so ("##b", "##c") produces ##bc and not bc; the reference refuses to build a file whose vocabulary carries the concatenated form instead. An end_of_word_suffix on that right side stays on: ("a", "##b</w>") produces ab</w>. The prefix composes with end_of_word_suffix, prefix then characters then suffix. An empty prefix reads as absent. Pairing a non-empty prefix with ByteLevel is refused by name, by BpeTokenizer's constructor and by TokenizerJsonLoader.LoadBpe — byte-level symbols are never prefixed while a merge's right side is still stripped, so the two halves would disagree, and the byte-level alphabet spells 0x23 as #, which lets the disagreement land on another existing id rather than raise. An end_of_word_suffix paired with ByteLevel is the opposite case: the two are independent properties, so a vocabulary can declare both, and where it does the suffix is silently ignored on the byte-level path rather than applied or refused — nothing here measures what tokenizers does with that pairing, so this is a documented gap, not a refusal.
BPE(..., byte_fallback=True) tokenizers new BpeVocabulary(vocab, merges) { ByteFallback = true } The unit is the symbol, and a symbol is one code pointdecision 0007's measurement 1, and the rule every line below rests on. An uncovered code point resolves into one <0xXX> piece per UTF-8 byte of it — uppercase hexadecimal — rather than into the unknown token: é is ['<0xC3>', '<0xA9>'], three pieces, an emoji four. All-or-nothing per symbol, never partial, which is that decision's decision 1 rather than 0050 §3: LoadBpe refuses a file declaring the flag without all 256 pieces (row above), and this constructor refuses such a vocabulary too, naming the first piece missing — BpeVocabulary is public and constructible without a loader, so the loader's refusal alone would not make it always true here. The expansion runs on the decorated symbol and before the merge loop, so a byte piece merges like any other symbol: declaring the merge <0xC3> <0xA9> gives an uncovered é['<0xC3><0xA9>']. continuing_subword_prefix and end_of_word_suffix are themselves encoded as bytes when the decorated string has no vocabulary entry — an uncovered é under continuing_subword_prefix: "##" is ['<0x23>', '<0x23>', '<0xC3>', '<0xA9>'], the ## becoming two # bytes, and under end_of_word_suffix: "</w>" the tail is <0x3C> <0x2F> <0x77> <0x3E> instead — bpe_byte_fallback.json's continuing_prefix and end_of_word_suffix cases measure the pair. With no unk_token declared at all the expansion still runs, unconditionally: unk_token_absent matches the reference exactly, so the branch is not gated on an unknown token being present on either side of this pairing. fuse_unk never fuses a byte-resolved symbol — only what still falls to the unknown token fuses among itself, as the row above already states. Divergence, beyond the incomplete-alphabet refusal above: tokenizers 0.23.1 also mis-orders a byte-resolved symbol immediately following one that fell to the unknown token — measured on a deliberately partial vocabulary, XY is ['<0x58>', '<unk>'] rather than ['<unk>', '<0x58>'] — which is unreachable here once the alphabet is complete, so no case reproduces it. Measured over bpe_byte_fallback.json's ten pipelines, encode and decode both, against tokenizers 0.23.1.
sentencepiece_model_pb2.ModelProto().ParseFromString(…) sentencepiece SentencePieceModelLoader.Load(path) Hand-written minimal protobuf reader (varint, length-delimited, fixed32). Pieces, scores, types, and unk/bos/eos/pad ids from trainer_spec. Scores are 32-bit floats widened to double, exactly as the Python binding does. The normalizer_spec is read, not merely inspected: its precompiled_charsmap becomes a PrecompiledNormalizer. Refuses a normalizer named without a map to apply, or a map that will not parse — nothing is decided from normalizer_spec.name.
sp.id_to_piece(i) / sp.get_score(i) sentencepiece vocab.Pieces[i].Piece / .Score Identical; scores compared at 1e-9 in the oracle.
sp.IsControl(i) / sp.IsUnknown(i) sentencepiece vocab.Types[i], vocab.IsMatchable(i) The type comes from the file. A previous constructor inferred it from ids 0/1/2, which is wrong for any model laying out differently; it was removed in Lodestar.Embeddings 0.6.0.

Lodestar.Embeddings — index persistence

Python Library C# Differences
numpy.save(path, matrix) numpy index.Save(path) / index.Save(stream) Versioned JSON whose vector block is base64-encoded raw little-endian IEEE-754 bits, not a .npy memory dump. Carries the normalization flag and an optional id per vector — a .npy header already carries shape/dtype, so the per-vector dimension is recoverable as shape[1], but the flag and the ids have nowhere to live in it.
numpy.load(path) numpy EmbeddingIndex.Load(path, options?) Static, not a constructor. Returns a queryable index rather than an array, and bounds every count against ArtifactLoadOptions before it sizes a buffer — the vector block by MaxTotalBytes in bytes before parsing, the rest by MaxArrayLength in elements.
numpy.load(path) / numpy.load(io.BytesIO(blob)) numpy NpyFile.Read(source, options?) The block itself, where the row above returns a queryable index: a .npy carries no ids and no normalize flag, so this is interop and not the artifact format. Three overloads where numpy has one, differing in what they copy — a Stream or a path is read straight into the array the returned block owns, while a ReadOnlyMemory<byte> is aliased, so the block's values are the caller's bytes and must not change while it lives; NpyBlock.OwnedArray is null on that path because there is no array to hand on, and docs/guides/performance.md has why the two contracts are not one. No mmap_mode, and no allow_pickle: descr: '|O' is refused by name on the header before the payload is touched rather than being offered as an option. Reads <f4 in C order alone — >f4, <f8, fortran_order: True, a scalar shape and anything past two dimensions are refused with what they held, where numpy.load reads every one of them. Bounded by ArtifactLoadOptions.MaxTotalBytes against the shape the header declares, before anything is allocated, and by a block ceiling of int.MaxValue / 4 elements that MaxTotalBytes cannot be raised past — a header declaring more is refused rather than read into an array its own count has overflowed.
faiss.write_index(idx, path) / faiss.read_index(path) faiss index.Save(path) / EmbeddingIndex.Load(path) Comparable in purpose, not in structure: Lodestar's index is exhaustive (IndexFlatIP-shaped), so there is no graph or quantizer to serialize. An approximate index is a separate decision, not made.
— (a parallel list[str] the caller keeps) index.Add(vector, id) / index.GetId(i) Deliberate addition: without ids in the file, a reloaded index is a wall of anonymous integers.

Lodestar.Embeddings — vector selection

Python Library C# Differences
keybert._mmr.mmr(doc_embedding, word_embeddings, words, top_n, diversity) keybert Mmr.Select(query, candidates, count, lambda) keybert parameterises diversity = 1 − λ and rounds its scores to four decimals. It returns its picks sorted by relevance, not by selection order, so only the selected set is comparable.

Lodestar.Fuzzy — applied fuzzy matching

Python Library C# Differences
fuzz.ratio(a, b) rapidfuzz Fuzz.Ratio(a, b) Indel similarity ×100. Case-sensitive (no preprocessing, like rapidfuzz). Every fuzz.* row here is identical at TextElement.CodePoint, the overload each scorer takes, which the corpus replays on every pair, emoji, CJK past the BMP and rapidfuzz's whitespace included. One bound applies: the mode rewrites each code point as one UTF-16 unit, of which 63,455 are rankable, so two strings holding more distinct code points are refused by every scorer but Fuzz.Ratio, which compares the code points themselves and stays identical (#982), and Fuzz.WRatio when one of them is empty, which it scores 0 as rapidfuzz does (#1057). The default unit is the UTF-16 unit (decision 0001), and it parts from rapidfuzz on text past the BMP (a surrogate pair counts twice and sorts below U+E000) and on token splits: it splits on U+0085 and U+00A0 and not on U+001C to U+001F, where rapidfuzz always splits on U+001C to U+001F and splits on U+0085 and U+00A0 only in a string holding a character above U+00FF.
fuzz.partial_ratio(a, b) rapidfuzz Fuzz.PartialRatio(a, b) Best sliding window (shorter over longer; both directions when lengths are equal).
fuzz.token_sort_ratio(a, b) rapidfuzz Fuzz.TokenSortRatio(a, b) Sort tokens then ratio.
fuzz.token_set_ratio(a, b) rapidfuzz Fuzz.TokenSetRatio(a, b) Shared tokens vs differences. 0 when either side has no words, as in rapidfuzz.
fuzz.WRatio(a, b) rapidfuzz Fuzz.WRatio(a, b) Weighted combination based on the length ratio.
process.extract(q, choices, limit=…, score_cutoff=…) rapidfuzz Process.Extract(q, choices, limit:…, scoreCutoff:…) Default scorer WRatio, score-descending order (index tie-break), cutoff. The cutoff filters after each candidate is scored in full; rapidfuzz passes it to the scorer, which can stop early, and the answer is the same.
process.extractOne(q, choices) rapidfuzz Process.ExtractOne(q, choices) Best candidate or null.
blocking deduplication — (application pattern) Deduplicator.FindClusters(...) Partition by blocking key + transitive closure (union-find). Avoids O(n²).

Lodestar.Text — indexing

No canonical Python library exposes a BK-tree; there is nothing to map against.

Python Library C# Differences
— (no Python counterpart) BkTree A Burkhard-Keller tree: a metric index over strings, built once and queried many times for "everything within edit distance k". Correct only over a distance satisfying the triangle inequality — the four factory methods bind the ones that qualify. Measured against a length-filtered linear scan in docs/guides/dictionary-lookup.md: worthwhile at k = 1, not past it.

Lodestar.Text — keyword extraction

Python Library C# Differences
rake_nltk.Rake(...).get_ranked_phrases_with_scores() rake-nltk Rake.Extract(text) Stop words and the token pattern are supplied, never downloaded — RakeOptions has no default corpus. IncludeRepeatedPhrases = false removes the duplicate before the degree and frequency tables, as rake-nltk does, so it changes the scores and not only the output. A tie breaks by phrase, ordinal descending over UTF-16 code unitsrake_nltk/rake.py:241 sorts (score, phrase) tuples with reverse=True over Python's code-point order — not by text order, matched rather than left unspecified. The two agree except when the deciding character is supplementary and the one it is compared against sits in U+E000-U+FFFF, the only range a surrogate pair's leading unit sorts below -- the same UTF-16-vs-code-point split decision 0001 already documents for the distances. Only the word-level pattern is injected into rake-nltk's word_tokenizer, so word splitting matches by construction; the phrase-boundary rule is separate and narrower — the generator also overrides rake-nltk's sentence_tokenizer to split on [.!?;:,\n] and clears its punctuations set, where PhraseTokenizer.Split breaks a run at any non-whitespace gap, and the two coincide only because the five corpus documents use nothing but ., , and ;, the exact intersection. Outside it they diverge: under the oracle's own configuration, "linear constraints - natural numbers" gives rake-nltk one 4-word phrase at 16.0 and the C# two 2-word phrases at 4.0 each, and the same split happens on a hyphenated compound, parenthesised text or a contraction. The direction matters — the C# reproduces rake-nltk's own default boundary behaviour; it is the oracle's configuration that departs from that default to make the corpus comparable at all.
summa.keywords.keywords(text, words=n, scores=True) summa TextRank.Extract(text) Numerical parity, checked at the 1e-9 every oracle corpus in this repository is — replaying the power-iteration loop by hand against the frozen corpus measures the actual agreement well past that, under 4.48e-13 on the loosest case. Rank returns the dominant left eigenvector; summa reads scipy.linalg.eig's first column without checking it is dominant, and for a near-bipartite co-occurrence graph it is not — and, when the graph's transition matrix has a repeated eigenvalue, which column is first is not even reproducible across machines: measured, two_sentences carries eigenvalue 0.85 at multiplicity 3, and a GitHub Actions runner and a developer machine disagree about the column order LAPACK returns for it. tools/generate_oracles.py does not trust summa's raw pick for that reason — it replaces summa.keywords._pagerank with a version that selects the dominant left eigenvector by eigenvalue rather than by column position, forced by reproducibility rather than chosen (decision 0005). summa also raises IndexError when words exceeds the graph's node count, where the C# returns what there is. A tie keeps the order gluing produced it in, matching Python's stable sort in summa's _extract_tokens/_format_results — but the corpus does not freeze that order where two scores sit within 1e-9 of each other, because there summa is sorting by floating-point noise rather than by the algorithm and a different BLAS breaks the tie the other way; the generator sorts each such run by phrase instead. Tokenization matches only as far as \b\w+\b reaches: summa tokenizes with PAT_ALPHABETIC = (((?![\d])\w)+), dropping digits and stripping acronym dots, where TextRankOptions.TokenPattern's default keeps both — measured, "achieve 95 percent" has summa drop 95 and leave achieve/percent adjacent where the C# nodes 95 and keeps them apart; "covid19" is summa's covid against the C#'s covid19; "U.S.A." is summa's usa against the C#'s three separate nodes u, s, a. None of the five corpus documents contains a digit, an acronym or intra-word punctuation, so the corpus cannot see any of this. The same class of divergence recurs in gluing: summa glues over text.split()'s whitespace tokens where Glue walks the regex token stream, and the two positions disagree as soon as one whitespace-delimited token holds more than one regex token.

Lodestar.Text.Similarity — MinHash, SimHash and LSH

Python Library C# Differences
MinHash(num_perm, seed).update(...), .hashvalues datasketch MinHash.Signature(tokens) The permutation coefficients are an input, not a seeddecision 0004's call applied again, because deriving them from a seed would oblige this to reproduce numpy's generator stream to agree. The reference exposes its own pair as MinHash.permutations and the corpus freezes them. Tokens are hashed by sha1_hash32, the first four bytes of SHA-1 little-endian, as the reference does. Exact parity, bit for bit: a signature is a list of hashes, so any difference means the algorithm diverged. Against which family is now a choice: datasketch 2.0.0 named three permutation schemes and made affine32 the default, so parity holds against scheme="legacy" — the default here too, and the reference's only family through 1.6.5 — and against scheme="affine32" through MinHashScheme.Affine32, which also reproduces the finalizer that family pre-mixes with. affine64 is not offered: it pairs with sha1_hash64 and is a second parity surface.
MinHash(...).jaccard(other) datasketch MinHash.Jaccard(left, right) The share of agreeing slots, exactly as the reference computes it. Static here rather than an instance method, because it reads two signatures and neither owns the comparison.
Simhash(features).value simhash SimHash.Fingerprint(tokens) 64 bits, MD5 read as a big-endian integer of which the low 64 bits — the digest's last eight bytes — decide the columns. Exact parity. The reference tokenizes text for you through a regex; this takes tokens, so the splitting is the caller's and matches whatever tokenizer produced them.
Simhash(a).distance(Simhash(b)) simhash SimHash.HammingDistance(left, right) Identical. Takes two fingerprints rather than two documents, so nothing is re-hashed to compare.
MinHashLSH(threshold, num_perm, weights) datasketch LshBanding.Solve(...) Public rather than hidden in a constructor, which is the difference this lot exists for: a threshold does not select a banding on its own, it selects one given the two error weights. The two error areas are the published LSH analysis's, integrated by the midpoint rule at 0.001; measured, that reproduces the reference's choice on all fifteen threshold and length pairs the corpus freezes.
MinHashLSH.insert / .query datasketch LshIndex.Add, LshIndex.Query No frozen corpus, deliberately. The reference's index is backed by a key-value store and returns keys in that store's order, so freezing its output would freeze a storage detail rather than the algorithm. The banding contract is what is checkable and what the tests state. This returns candidates in insertion order, each once; the reference returns a list whose order it does not define.

Lodestar.Text.Search — BM25 and rank fusion

Python Library C# Differences
BM25Okapi(corpus).get_scores(query) rank_bm25 Bm25Index.Score(query) Okapi BM25 over a CountVectorizer matrix of raw counts rather than over tokenized lists, so the analyzer, n-grams and stop words are the vectorizer's. Defaults are the reference's — k1 = 1.5 (not Lucene's 1.2), b = 0.75, epsilon = 0.25. Robertson's IDF with negatives floored at epsilon × mean(raw IDF), mean taken over the raw values; the floor is itself negative when that mean is, so scores can be negative and this does not clamp. Bm25Idf.Lucene offers the non-negative log(1 + …) form instead. A query term is one entry per occurrence, so a repeat counts twice. Exact parity, absolutely at 1e-12 (8 corpora).
BM25Okapi(corpus).get_top_n(query, docs, n) rank_bm25 Bm25Index.Top(query, n) Returns document indices and their scores rather than the documents themselves, since this scores a matrix and never held the text. Ties break by document index ascending, which the reference does not specify. Documents scoring zero are returned like any other.
(no canonical library) RankFusion.Rrf(rankings, k) Reciprocal rank fusion, Cormack et al. (2009): Σ 1 / (k + rank), rank one-based, k defaulting to 60. No Python reference is pinned — it is one formula, so it is checked by tests that state it rather than by a frozen corpus, the way Lodestar.Metrics' mean reciprocal rank is. A document repeated inside one ranking scores at its first position; ties break by first-seen order.

Lodestar.Metrics — classification metrics

Python Library C# Differences
accuracy_score(y_true, y_pred) scikit-learn Accuracy.Score(yTrue, yPred) Identical, normalize included. The overload taking a ConfusionMatrix scores only the samples that matrix kept. A sampleWeight holding a non-finite value or zero throughout is refused in _check_sample_weight's words here and on every label-span metric below, and weights summing to zero wherever the reference's numpy.average divides by them.
hamming_loss(y_true, y_pred, sample_weight=…) scikit-learn HammingLoss.Score(…) Identical, both shapes. On single-label input it is one minus Accuracy.Score and agrees with ZeroOneLoss.Score; on a label matrix it counts wrong labels where that one counts wrong rows — measured, 0.3333… against 1 on two samples over three labels. The 2-D form arrives row-major with a labelCount, as the other 2-D metrics take theirs, and sampleWeight is per row rather than per value.
zero_one_loss(y_true, y_pred, normalize=…, sample_weight=…) scikit-learn ZeroOneLoss.Score(…) Identical, normalize included as a bool as Accuracy.Score's already is. With weights and normalize=False the answer is the weight of the wrong samples rather than how many there are — measured 2.0 against a count of 1 — which is the reference's behaviour. On a label matrix a row is wrong if any of its labels is.
jaccard_score(y_true, y_pred, labels=…, pos_label=…, average=…, sample_weight=…, zero_division=…) scikit-learn JaccardScore.Score(…), .PerClass(…) Identical on the four averaging modes Precision.Score already implements, weights and labels included — it is that metric's shape with a different ratio, and shares its machinery. ZeroDivision.NaN and ZeroDivision.Throw have no counterpart: jaccard_score admits only 0, 1 and 'warn', and refuses nan with an InvalidParameterError; the two extra members are this package's, and under ZeroDivision.NaN an average skips the undefined classes as Precision.Score does. average='samples' is not offered, for the reason AveragePrecision gives. An absent pos_label under Averaging.Binary raises here, which is the refusal Precision.Score already makes rather than anything new. A weighted average whose supports cancel is refused, where the three sibling metrics answer the unweighted mean: jaccard_score drops its weights only when every support is zero (numpy.any) and otherwise reaches numpy.average, which raises ZeroDivisionError, while precision_recall_fscore_support catches that same error inside _nanaverage. ArgumentException in numpy's own sentence here, as everywhere else that call refuses. Only a negative sample_weight reaches it — measured, jaccard_score([0, 0, 1, 1], [0, 1, 1, 0], sample_weight=[1, 1, -1, -1], average='weighted').
confusion_matrix(y_true, y_pred, labels=…) scikit-learn ConfusionMatrix.Compute(…) Rows are true labels. Label order is the sorted union, or the caller's order left unsorted. Counts are double because sampleWeight is supported (decision 0003).
multilabel_confusion_matrix(y_true, y_pred, sample_weight=…, labels=…, samplewise=…) scikit-learn MultilabelConfusionMatrix.Compute(…) Identical, both shapes and samplewise included. Returns a stack of ConfusionMatrix rather than a type of its own: each entry is one class against everything else, which a two-label matrix already is, and its cells land where the reference puts them because its labels are 0 and 1 in that order. samplewise is structurally confined to the matrix overload, where scikit-learn refuses it at run time with "Samplewise metrics are not available outside of multilabel classification" — the call cannot be written here rather than being rejected. Under it a row's weight applies to each of that row's labels, since the matrix counts labels there.
class_likelihood_ratios(y_true, y_pred, labels=…, sample_weight=…, replace_undefined_by=…) scikit-learn LikelihoodRatios.Compute(…) Identical, all four undefined shapes included. Returns a small sealed type with named Positive and Negative rather than a tuple, which would carry neither names nor documentation. replace_undefined_by is two parameters here: it takes a scalar or a mapping of {"LR+": …, "LR-": …} there, a union C# has no equivalent of, and passing the same value to both reproduces the scalar form. A truth with no positive sample refuses the replacement on both sides and answers nan whatever was asked for, where a truth with no negative sample takes it — measured (nan, nan) against (1, 1) with the replacement set to 1, and nothing in the reference's signature says so. More than two distinct labels is ArgumentException carrying scikit-learn's own sentence, where it raises ValueError.
hinge_loss(y_true, pred_decision, labels=…, sample_weight=…) scikit-learn HingeLoss.Score(…), .MultiClass(…) Identical on every input either side defines, binary and one-decision-per-class alike. It reads a decision function rather than a label or a probability, the only member here that does, and charges until a margin of 1 — a prediction that is right but barely still costs something where ZeroOneLoss.Score counts it free. The multiclass form charges the true class's decision less the best of the others, Crammer and Singer's margin. posLabel is a parameter defaulting to 1 where scikit-learn infers the two classes; only the decision's sign is compared against it, so relabelling cannot move the number. One divergence, on a truth carrying a single class: scikit-learn maps every label to -1 through a LabelBinarizer with nothing to contrast and returns a value computed against the wrong side — measured 1.65 where the margins give 0.35, which is what an explicit posLabel answers here. A third label, a non-finite decision and weights summing to zero are refused as the reference refuses them; a non-finite weight is scored, NaN on both sides, because hinge_loss alone never calls _check_sample_weight.
precision_score(…, average=…) scikit-learn Precision.Score(…, Averaging…) All four modes. average=None is Precision.PerClass, a method rather than an enum member: it returns one value per class, not a scalar.
recall_score(…, average=…) scikit-learn Recall.Score(…, Averaging…) As above.
f1_score(…, average=…) scikit-learn F1.Score(…, Averaging…) As above.
fbeta_score(…, beta=…) scikit-learn FBeta.Score(…, beta, …) Finite beta ≥ 0; scikit-learn also accepts inf, which throws here.
classification_report(…) scikit-learn ClassificationReport.Compute(…), .ToText(digits) Structured and character-exact text. ZeroDivision.NaN renders NaN where Python writes nan; the numbers still match.
zero_division=0/1/np.nan scikit-learn ZeroDivision.Zero/One/NaN Values identical, macro and weighted averages included: as scikit-learn's _nanaverage does, a NaN class leaves the average with its weight, only every class being NaN makes the average NaN, and a weighted average whose support sums to zero is the unweighted mean (#861). The UndefinedMetricWarning has no equivalent; ZeroDivision.Throw is the opt-in replacement.
roc_auc_score(y_true, y_score) scikit-learn RocAuc.Score(…) Binary. posLabel is explicit here (default 1) where scikit-learn infers it.
roc_curve(y_true, y_score, pos_label=…, sample_weight=…, drop_intermediate=…) scikit-learn RocCurve.Compute(…) Identical, the leading point at an infinite threshold included — no sample is above it, so both rates are 0 there and the reference prepends it rather than deriving it. drop_intermediate defaults to true here as it does there, where the other two curves default to false; the asymmetry is reproduced rather than normalised, and this curve's rule is the collinear one — a point the curve does not bend at. Measured, a ten-sample fixture goes from 11 points to 5. A class absent from the input gives a NaN rate rather than a division by zero, which is what the reference warns about and returns. Returned as a sealed class per the curve-shape rule.
precision_recall_curve(y_true, y_score, pos_label=…, sample_weight=…, drop_intermediate=…) scikit-learn PrecisionRecallCurve.Compute(…) Identical, including that Thresholds is one shorter than Precision and Recall: the curve carries an endpoint at recall 0 and precision 1 that no threshold produces, and padding it would invent one. drop_intermediate defaults to false and drops a point whose true-positive count matches both neighbours — a different rule from roc_curve's, and the same one det_curve uses; measured, 11 points to 8. With no positive sample the recall is taken as 1 at every threshold, the same substitution AveragePrecision.Score reproduces.
det_curve(y_true, y_score, pos_label=…, sample_weight=…, drop_intermediate=…) scikit-learn DetCurve.Compute(…) Identical. The shortest of the three on the same input — 3 points where the ROC curve has 5 — because neither endpoint is carried: the curve starts where false positives stop being zero and stops where false negatives reach zero. Its points run by ascending threshold, the reverse of the other two. drop_intermediate defaults to false and shares precision_recall_curve's rule.
auc(x, y) scikit-learn Auc.Trapezoid(…) Identical, direction included: a curve given right to left gives the same magnitude as the same curve given left to right. x that neither increases nor decreases is ArgumentException where scikit-learn raises ValueError, and fewer than two points likewise. Over RocCurve.Compute's output it equals RocAuc.Score exactly — an invariant no oracle states, asserted over every fixture. Over a precision-recall curve it is deliberately not average precision: the trapezoid reads 0.7916666666666666 where the step sum reads 0.8333333333333333.
brier_score_loss(y_true, y_proba, sample_weight=…, pos_label=…, scale_by_half=…) scikit-learn BrierScore.Score(…), .MultiClass(…) Identical, both shapes and scale_by_half included. That parameter's 'auto' reads the input's shape — halving a one-dimensional binary probability and not halving a matrix — so it is a bool whose default differs per entry point rather than a string: scaleByHalf: true on Score and false on MultiClass, which reproduces both numbers. Measured, one matrix scores 0.245 unhalved and 0.1225 halved. pos_label is a parameter defaulting to 1 where scikit-learn infers the greater label present and refuses to guess for non-numeric labels — as RocAuc.Score's already is. A probability outside [0, 1] is ArgumentException carrying the reference's own sentence, which says less than 0 here and lower than 0 in log_loss; both wordings are kept. A yTrue with a third label is refused on the one-dimensional form, as the reference refuses it, rather than counted negative against posLabel.
log_loss(y_true, y_proba, normalize=…, sample_weight=…, labels=…) scikit-learn LogLoss.Score(…), .MultiClass(…) Identical, normalize included as a bool as Accuracy.Score's already is. The clip is machine epsilon, 2.220446049250313e-16, measured rather than assumed because it has moved across versions: a predicted 0 for the true class contributes -log(eps), anything below the clip scores the same as 0, and a perfect prediction reads 2.2204460492503136e-16 rather than 0 because the top is clipped too. A row that does not sum to 1 is neither refused nor renormalised — the reference warns and scores the values as given, and there is no warning channel here, so only the number carries it; RocAuc.MultiClass is stricter than its own reference on exactly that point and this is not. posLabel is a widening: log_loss has none, a one-dimensional column always describing the greater label, and passing labels reversed only warns and returns the same number — scoring about the other class is the same call on the complement, which the corpus pins. A yTrue with a third label is refused on Score with the reference's "different number of classes" sentence rather than counted negative against posLabel.
calibration_curve(y_true, y_prob, pos_label=…, n_bins=…, strategy=…) scikit-learn CalibrationCurve.Compute(…) Identical. It is sklearn.calibration, not sklearn.metrics — the one member of the calibration family that lives in the other module, named here rather than filed beside its siblings. Both arrays share a length and that length is not nBins: an empty bin is dropped, so it depends on the data — measured, four probabilities over five uniform bins return four points and four probabilities inside one bin return one. strategy is an enum rather than a string, and Quantile reads its edges from np.percentile's linear interpolation, not from the weighted percentile the weighted-percentile rule pinned for the medians — the two disagree, and reusing the weighted one would move the third decimal. Repeated probabilities collapse quantile edges onto each other and empty bins rather than balancing them, which the corpus pins. posLabel is a parameter defaulting to 1 where the reference infers it, as BrierScore.Score's already is. There is no sample_weight: the reference has none for this curve. Returned as a sealed class per the curve-shape rule.
roc_auc_score(…, multi_class=…) scikit-learn RocAuc.MultiClass(…, MultiClassRocOptions) ovr and ovo. Separate method: the overloads would be ambiguous. Strategy, averaging, labels and weights travel in MultiClassRocOptions, which also carries MaxDegreeOfParallelism — no scikit-learn equivalent, opt-in, sequential by default. sampleWeight refused for ovo, as in scikit-learn.
balanced_accuracy_score(…, adjusted=…) scikit-learn BalancedAccuracy.Score(…) Averages over the classes with a true sample, as scikit-learn does; adjusted divides by that same kept count, and returns NaN or -∞ when only one class is kept — the same two values scikit-learn returns. The overload taking a ConfusionMatrix scores only the classes that matrix holds: with an explicit labels subset, a dropped sample counts nowhere, not even in a denominator. balanced_accuracy_score has no labels parameter, so there is no reference value for that case.
matthews_corrcoef(…) scikit-learn MatthewsCorrelation.Score(…) scikit-learn hard-codes 0.0 when the denominator collapses; here it is ZeroDivision, defaulting to that value, with Throw available. An extension beyond parity, not a divergence in value. The overload taking a ConfusionMatrix scores only the classes that matrix holds; matthews_corrcoef has no labels parameter, so there is no reference value for a restricted matrix.
cohen_kappa_score(…, weights=…) scikit-learn CohenKappa.Score(…, KappaWeighting…) weights renamed weighting, because sampleWeight shares the signature. replace_undefined_by maps onto ZeroDivision, defaulting to NaN — scikit-learn's value; it also covers a view that holds no weight at all, where scikit-learn returns the same. The weighted forms depend on label order. The overload taking a ConfusionMatrix scores only the classes that matrix holds; cohen_kappa_score does take labels, so a reference value exists here, and on the fixture the tests pin the two agree.
confusion_matrix(…, normalize=…) scikit-learn ConfusionMatrix.ToArray(Normalization) A projection, not a parameter on Compute: several metrics here read a matrix, and fractions would make them silently wrong.

Lodestar.Metrics — regression metrics

Python Library C# Differences
mean_squared_error(…, multioutput=…) scikit-learn MeanSquaredError.Score(…), .PerOutput(…) multioutput is the choice of method plus an optional outputWeights span, not an enum — raw_values changes the return type, which decisions/0003 already ruled cannot be an enum member, and decisions/0021 applies that ruling here. 2-D targets arrive row-major with outputCount; there is no 2-D overload, because a span cannot carry one. Two refusals every metric in this block shares, both reproduced with the message their Python layer prints: a sampleWeight that is zero throughout gives check_array's "Sample weights must contain at least one non-zero number." — the rule is every weight zero, not the sum, so [-1, -2, -3] still scores — and outputWeights summing to zero give numpy.average's "Weights sum to zero, can't be normalized.", where the rule is the sum, so [1, -1] is refused and [-1, -1] scores. ValueError and ZeroDivisionError both become ArgumentException. The accumulation behind the mean is Neumaier-compensated (issue #127), so the answer is at least as accurate as numpy's pairwise reduction, not merely close to it.
root_mean_squared_error(…) scikit-learn RootMeanSquaredError.Score(…), .PerOutput(…) A type of its own: scikit-learn removed mean_squared_error(squared=False) in 1.6. The root is taken per output, before the reduction, so on more than one output the result is not the root of MeanSquaredError.Score — that is scikit-learn's order too. The underlying mean is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
mean_absolute_error(…) scikit-learn MeanAbsoluteError.Score(…), .PerOutput(…) As above for multioutput. The accumulation is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
median_absolute_error(…) scikit-learn MedianAbsoluteError.Score(…), .PerOutput(…) With sampleWeight, an averaged weighted percentile: the mean of the first value whose cumulative weight reaches half the total and the one just past the last that comes within one machine epsilon of it. That tolerance is scikit-learn's own (fraction_above > np.finfo(float64).eps) and it is load-bearing, not decoration: on sample_weight = [0.1] * 10 an exact comparison returns 4.0 where scikit-learn returns 4.5. A uniform weight is therefore usually the ordinary median but not always — measured, [0.7] * 10 gives 5.0 on the weighted path against 4.5 on the unweighted one, because there the overshoot is wider than an epsilon. Both sides agree, divergently, with scikit-learn.
mean_absolute_percentage_error(…) scikit-learn MeanAbsolutePercentageError.Score(…), .PerOutput(…) The denominator is clamped at numpy's machine epsilon, 2**-52not double.Epsilon, which is 292 orders of magnitude smaller. mean_absolute_percentage_error([0], [1]) is therefore 4503599627370496.0 on both sides. The accumulation behind the mean is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
max_error(y_true, y_pred) scikit-learn MaxError.Score(yTrue, yPred) No sampleWeight and no multioutput, because max_error has neither and refuses 2-D input. A worst case is not an average.
mean_squared_log_error(…) scikit-learn MeanSquaredLogError.Score(…), .PerOutput(…) Refuses a target at or below −1 on either side, as scikit-learn does — ArgumentException for its ValueError; the message additionally names the side, which costs no parity because no value is returned either way. The logarithm is numpy's log1p, reached through Kahan's identity rather than Math.Log(1.0 + x): on targets around 1e-9 the latter is out by 1.7e-8 relative, where this agrees with scikit-learn to a unit in the last place. The mean itself is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
root_mean_squared_log_error(…) scikit-learn RootMeanSquaredLogError.Score(…), .PerOutput(…) As above, and the root is taken per output before the reduction as in root_mean_squared_error. The underlying mean is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
r2_score(…, force_finite=…) scikit-learn R2.Score(…), .PerOutput(…), .VarianceWeighted(…) Two independent undefined cases, deliberately kept apart. Fewer than two samples is ZeroDivision, defaulting to NaN — scikit-learn's value, recorded in decisions/0020 — while a truth of zero variance over two or more samples is forceFinite. They do not overlap. One shape divergence: on fewer than two samples with more than one output, PerOutput returns one NaN per output, where r2_score returns a single scalar nan before it ever consults multioutput. No number differs — every scalar-returning path here still gives nan — and a one-element array would break PerOutput's own contract of one value per output. Both of R2's passes are Neumaier-compensated (issue #127): the answer is at least as accurate as numpy's pairwise reduction, not merely close to it — load-bearing on an ill-conditioned target, where a sequential sum measured 357× outside the oracle's tolerance.
explained_variance_score(…) scikit-learn ExplainedVariance.Score(…), .PerOutput(…), .VarianceWeighted(…) Takes forceFinite but no ZeroDivision: it has no fewer-than-two-samples case to route, so explained_variance_score([3], [5]) is 1.0, not nan, and PerOutput matches scikit-learn exactly there — the divergence noted for r2_score is r2_score's alone. Its five accumulations are Neumaier-compensated (issue #127) for the same reason R2's are, at least as accurate as numpy's pairwise reduction rather than merely close to it.
mean_pinball_loss(…, alpha=…) scikit-learn PinballLoss.Score(…, alpha, …), .PerOutput(…) Named for the loss rather than for the Python identifier's mean_ prefix, matching the other ten. alpha outside [0, 1] throws ArgumentOutOfRangeException where scikit-learn raises InvalidParameterError. The underlying mean is Neumaier-compensated (issue #127), at least as accurate as numpy's pairwise reduction rather than merely close to it.
mean_tweedie_deviance(y_true, y_pred, sample_weight=…, power=…) scikit-learn TweedieDeviance.Score(…) Identical, all five regimes and their domains included: below 0 only the prediction must be strictly positive, at 0 nothing is constrained, in [1, 2) the truth must be non-negative and the prediction strictly positive, and from 2 up both must be strictly positive. Each refusal carries scikit-learn's own sentence naming the power, as an ArgumentException where it raises ValueError. A power in the open interval (0, 1) names no distribution and is ArgumentOutOfRangeException where scikit-learn raises InvalidParameterError. At power 0 the number is MeanSquaredError.Score's exactly. y·log(y/ŷ) is taken as 0 at y = 0, numpy's xlogy, which is what makes a zero truth legal in [1, 2). No multioutput, because the reference has none on this function.
mean_poisson_deviance(y_true, y_pred, sample_weight=…) scikit-learn PoissonDeviance.Score(…) Identical. TweedieDeviance.Score at power 1, which is how the reference defines it too — asserted across the whole frozen corpus rather than on one pair. A zero truth is accepted and a zero prediction refused, with the power-1 sentence.
mean_gamma_deviance(y_true, y_pred, sample_weight=…) scikit-learn GammaDeviance.Score(…) Identical. TweedieDeviance.Score at power 2. Both operands must be strictly positive — a zero truth is refused here where the Poisson accepts one — and the number is unchanged by scaling both arguments together, measured 0.09824107126307435 on the worked case and on the same case times ten.
d2_tweedie_score(y_true, y_pred, sample_weight=…, power=…) scikit-learn D2Tweedie.Score(…) Identical on every input either side defines. At power 0 it is R2.Score exactly. A truth that never varies diverges in form, not in outcome: scikit-learn divides by the zero denominator and raises ZeroDivisionError, and this raises UndefinedMetricException naming the cause — where d2_absolute_error_score masks the same case and answers 0 on both sides. Fewer than two samples is nan with a warning there and ZeroDivision.NaN here, the parameter R2.Score already takes for the identical case. Multioutput is offered by neither: the reference raises "Multioutput not supported in d2_tweedie_score".
d2_pinball_score(y_true, y_pred, sample_weight=…, alpha=…, multioutput=…) scikit-learn D2Pinball.Score(…), .PerOutput(…) Identical, weights and both multioutput modes included. A column whose truth never varies scores 0 on both sides, the reference masking that denominator here where d2_tweedie_score divides by it. Fewer than two samples is nan, adjustable through ZeroDivision. Which of two candidate order statistics the denominator's quantile takes is unobservable — they differ only where the quantile is ambiguous and the pinball loss is flat there — measured over four fixtures at five alphas each. alpha outside [0, 1] throws ArgumentOutOfRangeException where scikit-learn raises InvalidParameterError, as PinballLoss.Score's already does.
d2_absolute_error_score(y_true, y_pred, sample_weight=…, multioutput=…) scikit-learn D2AbsoluteError.Score(…), .PerOutput(…) Identical. D2Pinball.Score at alpha = 0.5, an invariant no oracle states and a test asserts across every fixture — the two reach their baseline through different code, a quantile at one half and a median. Compares against the median where R2.Score compares against the mean, so an outlier in the truth does not flatter the model.

Lodestar.Metrics — clustering metrics

Python Library C# Differences
adjusted_rand_score(labels_true, labels_pred) scikit-learn AdjustedRand.Score(labelsTrue, labelsPred) Identical, degenerate cases included: an empty input and a single sample both score 1, and two independent partitions of four samples score -0.5.
normalized_mutual_info_score(…) scikit-learn NormalizedMutualInformation.Score(…) average_method is not a parameter: the arithmetic mean, scikit-learn's default, is the only normalizer reproduced. The other three (min, geometric, max) have no oracle row and are refused by absence rather than by exception.
fowlkes_mallows_score(labels_true, labels_pred) scikit-learn FowlkesMallows.Score(labelsTrue, labelsPred) Identical, and the degenerate cases split from the rest of this family: an empty input and a single sample score 0 here where the other five score 1, because there is no agreeing pair to count. Grouped as sqrt(tk/pk)·sqrt(tk/qk), the reference's own associativity.
adjusted_mutual_info_score(…) scikit-learn AdjustedMutualInformation.Score(…) Identical. average_method is not a parameter, as for normalized_mutual_info_score above. The expected mutual information uses a cumulative log(k!) table rather than a gammaln series. That table accumulates rounding: parity is established below about 20 000 samples and not above it (8.4e-10 relative there, against this corpus's 1e-9).
rand_score(labels_true, labels_pred) scikit-learn RandIndex.Score(labelsTrue, labelsPred) AdjustedRand.Score before the correction for chance: on [0,0,0,1,1,1] against [0,0,1,2,2,2] this scores 0.867 where AdjustedRand.Score scores 0.706. Uncorrected, so two independent labellings score well above zero, unlike the adjusted form.
mutual_info_score(labels_true, labels_pred) scikit-learn MutualInformation.Score(labelsTrue, labelsPred) NormalizedMutualInformation.Score before the [0,1] normalisation, in nats. Unbounded above. One divergence, deliberate: on an empty input scikit-learn 1.9.1 raises ValueError (log(0) inside mutual_info_score), not a documented refusal; this returns 0.0, matching every other metric in the family — see decision 0007.
cluster.pair_confusion_matrix(labels_true, labels_pred) scikit-learn PairConfusionMatrix.Compute(labelsTrue, labelsPred) The pair counts both Rand forms are computed from, as four named long properties rather than a ConfusionMatrix — that type counts labels, this counts pairs of samples, and reusing either the name or the type would have named the wrong thing. ToArray() reproduces numpy's [[C00,C01],[C10,C11]] shape for ported code.
homogeneity_score(…) scikit-learn Homogeneity.Score(…) Identical.
completeness_score(…) scikit-learn Completeness.Score(…) Identical, and implemented as Homogeneity with the two labellings exchanged, which is scikit-learn's own definition.
v_measure_score(…, beta=1.0) scikit-learn VMeasure.Score(…) beta is not a parameter: the default of 1 is the harmonic mean, and no reference value exists here for another weighting.
homogeneity_completeness_v_measure(…) scikit-learn the three calls above No combined call: three doubles would be a tuple or a record, and each metric is cheap enough to ask for on its own. The contingency table is rebuilt per call, which is O(n) each time.
silhouette_score(X, labels) scikit-learn Silhouette.Score(labels, features, featureCount) Euclidean only. scikit-learn takes some twenty metric= names; each admitted here would be a parity claim to keep, so another metric goes through the precomputed path. 2-D samples arrive row-major with a feature count, as the regression metrics take 2-D targets.
calinski_harabasz_score(X, labels) scikit-learn CalinskiHarabasz.Score(…) Identical, degenerate clusterings included. Clusters with no spread at all score 1 rather than dividing by zero, which is the reference's own guard — measured, four identical points in two clusters and two well-separated points each duplicated both read 1. A label count outside [2, n - 1] is ArgumentException carrying scikit-learn's sentence, "Number of labels is k. Valid values are 2 to n_samples - 1 (inclusive)", the same range silhouette_score and davies_bouldin_score refuse. No metric parameter and no precomputed-distance form, because the reference has neither: the score reads cluster centroids and a distance matrix does not carry them. 2-D input arrives row-major with a featureCount, as Silhouette.Score's does.
davies_bouldin_score(X, labels) scikit-learn DaviesBouldin.Score(…) Identical. Lower is better, the opposite direction to every other clustering score here. Two clusters sharing a centroid contribute 0 rather than an infinity: the reference substitutes infinity for the zero distance before dividing, so the pair drops out of the maximum — measured, a perfect clustering and a fully degenerate one both read 0. Same refusal range and sentence as calinski_harabasz_score, and no precomputed-distance form for the same reason.
silhouette_score(D, labels, metric='precomputed') scikit-learn Silhouette.ScoreFromDistances(labels, distances) A method of its own, not an overload: a matrix and a feature block are both a span of double, so the two signatures would collide (decisions/0021 applied to an input).
silhouette_samples(X, labels) scikit-learn Silhouette.PerSample(…), .PerSampleFromDistances(…) Identical, cluster of one sample included: it scores 0, measured, rather than dividing by zero. The refusal outside [2, n-1] distinct labels is scikit-learn's ValueError, reproduced as ArgumentException with its sentence.

Lodestar.Metrics — ranking metrics

Python Library C# Differences
dcg_score(y_true, y_score, k=…, log_base=…, sample_weight=…, ignore_ties=…) scikit-learn Dcg.Score(…) Identical, every parameter included since #216. The gains are linearΣ relevance / log(rank + 1) — as scikit-learn's are, not the 2^relevance − 1 form much of the literature uses: on [3, 2, 1, 0] ranked perfectly that is 4.7618595071429155 against 9.392789260714373. 2-D input arrives row-major with a labelCount, as the regression and clustering metrics take theirs; there is no 2-D overload, because a span cannot carry one. A k past the label count scores the whole row, as it does in scikit-learn; a k below 1 is ArgumentOutOfRangeException where scikit-learn raises InvalidParameterError, and a logBase outside (0, ∞) — zero, negative, NaN or infinite — is refused the same way, against the same constraint scikit-learn prints as "must be a float in the range (0.0, inf)"; a base below 1 is inside that interval and accepted on both sides, taking the score negative. A negative relevance is accepted and can give a negative score, which dcg_score does too. sampleWeight weights the mean over queries and cancels over a single one; a vector summing to zero is ArgumentException in numpy.average's own sentence where the reference raises ZeroDivisionError from that same call, and a negative weight is accepted on both sides and takes the mean outside the range the page promises — frozen in ranking_weighted.json, 1.0840593484403573 where the unweighted mean is 3.8424094674672755, and -3.0 at k = 2.
ndcg_score(y_true, y_score, k=…, sample_weight=…, ignore_ties=…) scikit-learn Ndcg.Score(…) Identical since #216, sample_weight included. No log_base, because ndcg_score has none — the discount cancels in the ratio only when both halves share a base, and scikit-learn shares base 2. A row where nothing is relevant scores 0 rather than dividing by zero, which is scikit-learn's value too. The ideal is computed without tie averaging on both sides. A negative relevance is refused with scikit-learn's own sentence, "ndcg_score should not be used on negative y_true values." — unguarded, y_true = [1, -1] against y_score = [0.1, 0.9] scores -1.
ignore_ties=False (the default) scikit-learn ignoreTies: false The closed form, not a permutation enumeration: within a tied group the mean relevance times the sum of the discounts of the positions it occupies is the average over the permutations, which is what scikit-learn computes. Load-bearing rather than cosmetic — on a row whose four scores are equal it gives 0.8069136566720543 against 0.6138273133441086. Ties are exact equality of the score on both sides; a tolerance would merge scores np.unique keeps apart.
ignore_ties=True scikit-learn ignoreTies: true Not a parity claim on a row that has ties. _dcg_sample_scores reaches this path through a bare np.argsort, whose default is an unstable quicksort, so the order scikit-learn gives a tied group is undefined. Here it is defined — equal scores rank by descending index, the order top_k_accuracy_score's explicit kind="mergesort" gives. The two agree on every row of the frozen corpus, which is 4 and 6 documents wide; on a wider one they may not, and the frozen values would then depend on the numpy build that captured them. Untied rows are unaffected, and they are what ignore_ties is for.
top_k_accuracy_score(y_true, y_score, k=…, normalize=…, sample_weight=…) scikit-learn TopKAccuracy.Score(…) One widening, no divergence in value, sample_weight included since #216 — with weights normalize=False returns the sum of the weights of the hits rather than how many there are, measured 7.0 against the unweighted 3.0, and because that path never divides it does not refuse a zero-sum vector at all, where the fraction does — what it returns there is the weighted sum of the hits, 3.0 on weights [1, 1, 1, -3] whose total is zero. scikit-learn infers the class set from y_true and refuses a score row wider than what it found unless given labels; here classCount is a parameter, so a class no sample carries raises nothing. On any input scikit-learn accepts, the two agree — ties included, because both take the top k of a stable sort. k below 1 is ArgumentOutOfRangeException where scikit-learn raises InvalidParameterError, and an empty y_true or a class outside [0, classCount) is ArgumentException where scikit-learn raises ValueError.
— (no counterpart) ReciprocalRank.Score(…) Not verified against a reference. Measured on scikit-learn 1.9.1, dir(sklearn.metrics) carries nothing matching reciprocal, so there is no corpus to freeze; the definition is pinned by tests instead, under decisions/0005, which also says what would retire the exception. The definition: the reciprocal of the rank of the first relevant document, averaged over queries, a query with no relevant document contributing 0 rather than being dropped from the average.
coverage_error(y_true, y_score, sample_weight=…) scikit-learn CoverageError.Score(…) Identical, degenerate rows included: a sample with no relevant label contributes 0 rather than the label count, so the mean can sit below 1 — measured, 0.5 on two samples one of which is empty. A single label column is refused with scikit-learn's own sentence, "binary format is not supported", where label_ranking_average_precision_score accepts one and returns 1; that divergence is the reference's, reproduced rather than smoothed. A sample_weight summing to zero raises ArgumentException with numpy's "Weights sum to zero, can't be normalized." where scikit-learn raises ZeroDivisionError from the same numpy.average, and where label_ranking_average_precision_score returns NaN instead. A negative weight is accepted on both sides and takes the result out of its range — measured, 5.0 on the two-sample worked case. 2-D input arrives row-major with a labelCount, as the other 2-D metrics take theirs.
label_ranking_average_precision_score(y_true, y_score, sample_weight=…) scikit-learn LabelRankingAveragePrecision.Score(…) Identical, the two places the reference disagrees with its siblings included. A single label column is accepted and scores 1, where coverage_error and label_ranking_loss refuse it — the reference validates this one differently, and making the three agree would invent a divergence instead of copying one. A sample_weight summing to zero gives NaN rather than raising, because the reference divides by the weight sum directly here instead of calling numpy.average. A negative weight is accepted and takes the result out of [0, 1] — measured, -0.33333333333333337. A sample where every label or no label is relevant scores 1 on both sides.
label_ranking_loss(y_true, y_score, sample_weight=…) scikit-learn LabelRankingLoss.Score(…) Identical, tie handling included: an irrelevant label sharing a relevant one's score counts as outranking it, so a sample whose scores are all equal scores 1 rather than 0.5. A single label column is refused with "binary format is not supported", where label_ranking_average_precision_score accepts one. A sample_weight summing to zero raises ArgumentException with numpy's "Weights sum to zero, can't be normalized.", where the average precision returns NaN. A negative weight is accepted and takes the result out of [0, 1] — measured, 2.0. A sample where every label or no label is relevant holds no pair to order and contributes 0 on both sides.
average_precision_score(y_true, y_score, average=…, pos_label=…, sample_weight=…) scikit-learn AveragePrecision.Score(…), AveragePrecision.PerLabel(…) Identical on every input either side defines, binary and label matrix alike, and it is a sum over the steps of the precision-recall curve rather than the trapezoid auc(recall, precision) takes — measured 0.8333333333333333 against 0.7916666666666666 on the worked case, and 0.5 against 0.75 on a row of tied scores. With no positive sample scikit-learn warns "No positive class found in y_true, recall is set to one for all thresholds" and returns 0.0; that value is reproduced rather than refused, where RocAuc.Score on the same walk throws. average='macro', 'micro' and 'weighted' are Averaging.Macro, Averaging.Micro and Averaging.Weighted; average=None is PerLabel. average='samples' is not offeredAveraging has no such member, and it is shared with Precision, Recall, F1 and FBeta, none of which implements it either. Three weight vectors diverge, all measured: one summing to zero, [1, 1, 1, -3], gives 0.5 there through a numpy divide-by-zero warning and -0 here; every weight 0 raises ValueError there and returns 0 here; and a pos_label no sample carries raises ValueError there and is the no-positive case here, 0posLabel being a parameter where scikit-learn infers it, as TopKAccuracy.Score's classCount already is. A negative weight leaving the total positive agrees: 0.75 on both.

Lodestar.Conformal — split conformal prediction

Python Library C# Differences
SplitConformalRegressor(estimator, confidence_level=1-α, prefit=True).conformalize(Xc, yc) then .predict_interval(X) MAPIE SplitConformal.AbsoluteResidualsSplitConformal.QuantileSplitConformal.Interval Identical on every input MAPIE answers, to the last bit — the frozen corpus asserts MAPIE's own bounds against ŷ ± q on four splits. Three calls rather than one object, and no estimator: the quantile is returned to the caller, so the number carrying the guarantee is visible rather than held. One divergence, deliberate: when α < 1/(n+1) MAPIE raises ValueError, and under allow_infinite_bounds=True returns a finite interval whose half-width is the largest calibration score; this returns double.PositiveInfinity, so the interval is the whole line — see decisions/0007. A NaN calibration score is refused, where MAPIE's nanquantile drops it.
SplitConformalClassifier(estimator, confidence_level=1-α, conformity_score="lac", prefit=True) then .predict_set(X) MAPIE SplitConformal.LeastAmbiguousScoresSplitConformal.QuantileSplitConformal.PredictionSet Identical at ConformalQuantileRule.MapieClassification, the empty prediction set included: when no class clears 1 − q both return nothing, and the corpus carries a case that does. predict_set returns a (n, classes, 1) array; this returns one bool[] per sample, in the class order the calibration block was given — that order is not checked on either side. At the default ConformalQuantileRule.Ceiling the set can differ by one rank: predict_set reads numpy.quantile(..., method="higher"), the 19th of 19 scores at α = 0.1 where the ceiling rule reads the 18th — see decisions/0005. The inclusion test is MAPIE's own, (1 − p) − q ≤ 1e-8. Same k > n divergence as the row above, where predict_set raises with no flag to suppress it, and a NaN calibration score is refused where predict_set returns an empty set for every sample.
numpy.quantile(scores, (1-α)*(n+1)/n, method="higher") numpy SplitConformal.Quantile(scores, α) Identical to SplitConformal.Quantile(scores, α, ConformalQuantileRule.MapieClassification) at MAPIE's level (n+1)(1-α)/n, and not the same rule as the default, contrary to what much of the literature's pseudocode suggests: higher indexes ceil(p(n-1)) and disagrees on 891 of 4000 random (n, α) pairs. method="inverted_cdf" is the ceiling rule algebraically and still disagrees on 7 of the same 4000, where evaluating the level in floating point moves pn across an integer. MAPIE's regressor follows the ceiling rule and so does the default; its classifier reads this quantile.
ResidualNormalisedScore(residual_estimator=..., prefit=True) MAPIE SplitConformal.NormalisedResiduals(yTrue, yPredicted, residualEstimates) and .NormalisedInterval(prediction, residualEstimate, quantile) The adaptive-width score, reproduced to 0.0 on both bounds against MAPIE 1.5.0 with both estimators prefit. Takes rather than the model that produced it, which is this package's shape throughout. One divergence: a zero, negative or NaN estimate is refused where MAPIE floors it at 1e-8 — the floor is MAPIE's defence against its own residual model, and here the estimate is the caller's argument, so flooring turns a bug into an interval of width q · 1e-8 that reads as certainty. MAPIE's prefit contract is that the estimator's predict returns and not its log.
GammaConformityScore, CV and Jackknife+ regressors MAPIE — (no counterpart) The split path is what ships. A multiplicative score for a strictly positive target is a different thing from the adaptive width above, and waits on a caller under decision 0003's rule.

Lodestar.Decomposition — truncated SVD

Python Library C# Differences
TruncatedSVD(n_components=k, algorithm="randomized").fit(X) scikit-learn TruncatedSvd.Fit(matrix, k) Ω is an input, not a seed: pass RandomMatrix to reproduce a Python run, since a Seed drives Lodestar's own generator (decision 0004). Over a shared Ω the two agree to 0.0, not to a tolerance. transpose="auto" is not offered, so a matrix with fewer rows than columns is factorized as written where scikit-learn would swap the products — that shape is frozen against randomized_svd(..., transpose=False) rather than against the estimator, since the estimator would answer about the transpose; algorithm="arpack" has no counterpart at all. The rank is bounded by the row count as well: _truncated_svd.py constrains only n_components >= n_features, so a 40 × 500 matrix at k = 60 is a fit there and an ArgumentOutOfRangeException here. That bound is forced rather than inherited — past k > rows the range finder's economic factorizations narrow the basis below k, and the truncation would throw out of Array.Copy instead of answering. A stored NaN or infinity is refused with ArgumentException by Fit and Transform, where scikit-learn's input check raises ValueError.
svd.transform(X) scikit-learn TruncatedSvd.Transform(matrix) X · componentsᵀ, identical. fit_transform is deliberately absent: for the randomized solver scikit-learn's returns the projection and not U · Σ, and the two differ by the solver's approximation error — two numbers under one name.
svd.components_, svd.singular_values_ scikit-learn Components, SingularValues Row-major k × n_features and k. Signs pinned by svd_flip on the right vectors, which is what TruncatedSVD itself asks for (u_based_decision=False) and not what the bare randomized_svd does — the two disagree by a sign on four of this corpus's six fixtures.
svd.explained_variance_, svd.explained_variance_ratio_ scikit-learn ExplainedVariance, ExplainedVarianceRatio Per-component variance of the projection (ddof=0), over the input's total column variance from mean_variance_axis. Identical. Taking it over U · Σ instead, as scikit-learn did before 1.6, moves the third decimal.
power_iteration_normalizer=... scikit-learn PowerIterationNormalizer All four values, including Auto's rule — None below three iterations, Lu at or above. A normalizer narrows the block to min(rows, columns) exactly as scipy's qr(mode="economic") and lu(permute_l=True) do.
PCA, SparsePCA, DictionaryLearning scikit-learn — (delegated) PCA in particular is not the SVD with centring bolted on: centring a CsrMatrix subtracts a column mean from every stored zero, so it is a different computation rather than a step in front of this one — scikit-learn refuses a sparse matrix to PCA for the same reason. Dense projection is delegated: Microsoft.ML's ProjectToPrincipalComponents on any target here, or NumFlat's PrincipalComponentAnalysis on net8.0 and above (decision 0004), or Meta.Numerics' PrincipalComponentAnalysis under MS-PL (decision 0004). What ML.NET cannot report is the row below.
PCA(svd_solver="full").fit(X) then .explained_variance_, .explained_variance_ratio_ scikit-learn PrincipalComponentVariance.Compute(matrix, rowCount, columnCount)ExplainedVariance, ExplainedVarianceRatio Identical at 1e-9 on nine frozen blocks, the n < p edge included: the component count is min(n_samples, n_features) on both sides, and centring leaves the last component of a wide block at zero. np.cumsum(explained_variance_ratio_) is CumulativeExplainedVarianceRatio, and explained_variance_.sum() is TotalVariance. The eigenvalues come from the Gram matrix rather than an SVD of the centred block, the trade scikit-learn's own covariance_eigh makes for tall input (decision 0003). Two divergences: fewer than two rows throws, where scikit-learn divides by n − 1 = 0; and a matrix with no variance throws, where scikit-learn answers NaN for every ratio. No components, no mean, no transform and no fractional n_components: the projection is the row above's.

Lodestar.Decomposition — non-negative matrix factorization

Python Library C# Differences
NMF(n_components=k, solver="mu", init="nndsvd").fit(X) scikit-learn Nmf.Fit(matrix, k) Ω is an input, as for the SVD. nndsvdar is not offered — it draws from numpy's Gaussian stream, so it could not be checked (decision 0004). solver="cd" and the alpha_W/alpha_H regularization are out of scope for 0.1.0. The rank is bounded as scikit-learn bounds it, at min(n_samples, n_features), and the corpus freezes the k == columns ≤ rows edge against NMF itself. A negative value, a NaN or an infinity in the matrix is ArgumentException, where scikit-learn raises ValueError.
NMF(init="custom").fit_transform(X, W=W0, H=H0) scikit-learn Nmf.Fit(matrix, initialWeights, initialComponents) Identical, the rank included: it is read off the two blocks' lengths and carries no column bound, so k == columns is reachable through this overload and not through the one above. tol = 0 makes the iteration count an input, which is what the corpus freezes. A W₀ with a column of zeros keeps it: scikit-learn replaces that column's zero sum with 1.0 before dividing where this floors every zero denominator to EPSILON, and the corpus carries the case that shows both reach the same zeros. A matrix with no row or no column is ArgumentException, as scikit-learn raises ValueError. One divergence: a NaN or an infinity in W₀ or H₀ is refused with ArgumentException, where scikit-learn accepts it and returns factors of NaN.
nmf.components_, nmf.n_iter_, nmf.reconstruction_err_ scikit-learn Components, Iterations, ReconstructionError Identical. Weights is what fit_transform returns.
beta_loss="frobenius" | "kullback-leibler" scikit-learn NmfBetaLoss Both, at solver="mu". Other β values are not offered.
init="nndsvd" | "nndsvda" scikit-learn NmfInitialization Both, over the same Ω. init="random" and "nndsvdar" are absent for the same reason: neither can be reproduced without numpy's generator.
NMF.transform(X_new) scikit-learn — (no counterpart yet) It is a factorization with H held fixed rather than a projection, so it is a solve and not a product; out of scope for 0.1.0.

Lodestar.Stats — hypothesis tests

Python Library C# Differences
scipy.stats.ttest_ind(a, b, equal_var=True) scipy TTest.Independent(a, b, Alternative.TwoSided, Variance.Equal) The default differs. TTest.Independent defaults to Variance.Welch; scipy's ttest_ind defaults to equal_var=True, which is Student's. Pooling is only correct when the two populations really share a variance, and a default that is wrong in the common case costs more than a word at the call site. Pass Variance.Equal for scipy's default. Otherwise identical, df fractional under Welch.
scipy.stats.ttest_rel(a, b) scipy TTest.Paired(a, b) Identical. Delegates to TTest.OneSample on the pairwise differences.
scipy.stats.ttest_1samp(a, popmean) scipy TTest.OneSample(a, populationMean) Identical.
scipy.stats.mannwhitneyu(x, y) scipy MannWhitney.Test(x, y) Identical on every input scipy answers, ties included — scipy computes an exact p-value on tied data too rather than refusing, and this matches. method='exact' past x.Length * y.Length > 20,000 is ArgumentOutOfRangeException here; scipy pays the enumeration cost instead. method='auto' never crosses that bound on its own, falling back to the asymptotic answer.
scipy.stats.wilcoxon(x, y) scipy Wilcoxon.Paired(x, y) Identical on every input scipy answers. method='exact' past 500 ranked values is ArgumentOutOfRangeException here — scipy's own exact total, 2^n, overflows a double to infinity past n = 1023, and this package refuses comfortably inside that margin rather than dividing by an infinite denominator. One measured divergence on method='asymptotic': with every difference zero, wilcoxon([0, 0, 0], method='asymptotic') returns pvalue=nan in scipy 1.18.0 (a zero-variance divide), where Wilcoxon.OneSample on the same three zeros, method: ExactMethod.Asymptotic, returns (0.0, 1.0) — the same answer Auto and Exact give on both sides, because there is no evidence either way regardless of which null distribution would have been consulted.
scipy.stats.chisquare(f_obs, f_exp) scipy ChiSquare.GoodnessOfFit(observed, expected) Identical. expected defaults to a uniform expectation across every category, the same default f_exp=None gives.
scipy.stats.chi2_contingency(table) scipy ChiSquare.Contingency(table) Identical, correction=True (Yates) matched by default.
scipy.stats.fisher_exact(table) scipy FisherExact.Test(table) Identical below the size bound. The exact enumeration's cost is proportional to the table's total, so a table summing past 1,000,000 is ArgumentOutOfRangeException here; scipy runs it regardless of how long that takes.
scipy.stats.ks_2samp(a, b) scipy KolmogorovSmirnov.TwoSample(a, b) Identical below the size bound, NaN input included (both return (nan, nan), and StatisticSign is 0 here where scipy's nan has no int equivalent). For two samples of the same size, two-sided, the default is scipy's: exact while n is at most 10,000 and asymptotic above. Two divergences remain, for unequal sizes and one-sided alternatives. There ExactMethod.Auto is exact only while a.Length * b.Length is at most 10,000, where scipy's is exact while max(n, m) is, so their p-values can differ between the two thresholds. And ExactMethod.Exact past a product of 1,000,000 is ArgumentOutOfRangeException for them, a bound on the table walk's time, which grows with the product while its allocation no longer does; scipy runs it regardless. ks_1samp has no counterpart: it compares a sample against a named distribution's CDF, and this package has no distributions namespace to supply one from — inventing one to serve a single test would be a second package's worth of surface.
scipy.stats.f_oneway(*groups) scipy OneWayAnova.Test(groups) Identical, the degenerate case included: two groups each internally constant at the same value drive both sums of squares to exactly zero, and both sides answer (NaN, NaN) rather than raising.
scipy.stats.kruskal(*groups) scipy KruskalWallis.Test(groups) One measured divergence. A fully tied pooled sample makes the tie correction, 1 - (t³ - t) / (n³ - n), exactly zero; scipy divides by it anyway and returns (nan, nan) with a warning, where KruskalWallis.Test raises ArgumentException — a division that would silently produce a NaN or infinite statistic from ranks that carry no information at all is refused instead of performed. Compare OneWayAnova.Test above, whose analogous degenerate input scipy and this package both answer with NaN.
scipy.stats.shapiro(x) scipy ShapiroWilk.Test(x) Identical inside Royston's fitted range. Outside 3 <= n <= 5000 — the range the p-value transform is fitted over — scipy warns (SmallSampleWarning) and answers (nan, nan) anyway; ShapiroWilk.Test raises ArgumentException rather than extrapolating a number the fit does not cover.
scipy.stats.false_discovery_control(p, method='bh') scipy MultipleComparisons.BenjaminiHochberg(p) Identical.
scipy.stats.false_discovery_control(p, method='by') scipy MultipleComparisons.BenjaminiYekutieli(p) Identical.
— (no counterpart) MultipleComparisons.Bonferroni(p) scipy has no bonferroni method under false_discovery_control, or elsewhere in scipy.stats. There is nothing to replay, so the oracle corpus states the definition instead of freezing a reference call: min(p × n, 1) per value.
nan_policy='propagate' | 'raise' | 'omit' scipy NanPolicy Identical on the eleven entry points scipy gives it to, and deliberately absent from the five it does not: chi2_contingency, fisher_exact and false_discovery_control take no nan_policy, so neither do ChiSquare.Contingency, FisherExact.Test or the MultipleComparisons methods. Propagate is the default on both sides. Omit drops pairs where the inputs are aligned — TTest.Paired, Wilcoxon.Paired, and ChiSquare.GoodnessOfFit when an expectation is given — and values elsewhere, matching ttest_rel and ttest_ind respectively. One divergence survives omission by design: where scipy answers (nan, nan) with a warning on a sample omission left degenerate, this package still raises, because omission is a filter and not a second policy (decision 0007). ChiSquare.Contingency continues to refuse a NaN cell outright — a contingency table's cells are counts, not measurements, and the expected-frequency table divides by their marginals.

Lodestar.Extensions.AI — Microsoft.Extensions.AI interoperability

This package adds no arithmetic, so the rows below map an ecosystem adapter, not a computation: the Python column names the class a framework asks for when it wants embeddings from a local model, and the C# column names the interface .NET's own AI stack asks for. The vectors are Lodestar.Onnx's either way — see the ONNX rows above for what they are and how they are checked.

Python Library C# Differences
HuggingFaceEmbeddings(model_name=...).embed_documents(texts) langchain-huggingface OnnxEmbeddingGenerator.GenerateAsync(values) Both hand a framework a local embedder behind that framework's interface. The task comes back already completed — the model runs in-process, so there is nothing to wait for — where the Python call is plainly synchronous and says so in its signature. EmbeddingGenerationOptions.Dimensions is checked, not honoured: an ONNX model's output width is fixed at export, so a width the model does not produce is refused rather than silently ignored, and ModelId is not read at all because this generator holds exactly one model.
embeddings.embed_query(text) langchain-core OnnxEmbeddingGenerator.GenerateAsync(values) with one text There is no single-text member: Microsoft.Extensions.AI ships GenerateVectorAsync as an extension over the same interface, so adding one here would be a second spelling of a call the abstraction already offers.
— (no counterpart) OnnxEmbeddingGenerator.GetService(serviceType) Service resolution is how Microsoft.Extensions.AI lets a consumer reach past the abstraction; LangChain has no equivalent, since a Python caller holds the concrete object. It answers for EmbeddingGeneratorMetadata, for the underlying OnnxTextEmbedder — which is the only way to reach the single-sequence entry point through the interface — and for the generator itself.
del embeddings OnnxEmbeddingGenerator.Dispose() The generator owns the embedder it was given, so disposing it closes the native session. Python's reference counting makes the question invisible; here it is a promise the page states, because a consumer holding the interface cannot see what is underneath.

Lodestar.Extensions.MathNet — Math.NET matrix interoperability

No Python call maps here either: the rows below name the layout change a SciPy user makes without thinking about it, and its C# counterpart. scipy.sparse and Math.NET both store a matrix in compressed sparse row form, as CsrMatrix does, so what changes is whose type holds it.

Python Library C# Differences
scipy.sparse.csr_matrix((values, indices, indptr), shape=(m, n)) scipy MathNetInterop.ToSparseMatrix(matrix) Both take the three compressed-row arrays as they are. scipy leaves an unsorted row alone until something asks — has_sorted_indices is a flag a caller checks and sort_indices() is a call a caller makes. This always sorts, and adds duplicate columns together, because Math.NET reaches a cell by searching the row and would otherwise answer zero for a value it holds. The cost is one comparison per stored value on a matrix already in order, which is everything the vectorizers produce (decision 0003).
sparse.toarray() then rebuilding scipy MathNetInterop.ToCsrMatrix(matrix) The densify-and-rebuild round trip has no counterpart here and does not need one: a Math.NET matrix already in compressed-row form hands over its three arrays directly, and only a dense or compressed-column one is walked cell by cell.
scipy.sparse.csc_matrix, coo_matrix, dok_matrix scipy — (no counterpart) CsrMatrix is the one layout this repository has, so there is nothing to convert to. A Math.NET matrix in another storage still converts from, through the walking path.
numpy.asarray(sparse.todense()) numpy CsrMatrix.ToDense() (Lodestar.Abstractions) Already there, and Math.NET builds a DenseMatrix from a double[,] unaided — which is why this package offers no dense pair.

Lodestar.Extensions.VectorData — Microsoft.Extensions.VectorData provider

No Python call maps here: the rows below map an interface a .NET consumer asks for onto the store that answers it. The rankings inside are Lodestar.Embeddings' and Lodestar.Text's, checked by their own rows above — Bm25Index.Top and RankFusion.Rrf among them.

Python Library C# Differences
Microsoft.Extensions.VectorData provider conformance — (no Python reference) LodestarVectorStore There is no Python call to map. This is an interface adapter, so conformance is proven by driving the abstractions as a consumer does — constructing a store, upserting typed records, searching, filtering and fusing — rather than by replaying a frozen corpus. Two deliberate refusals: LodestarVectorStore.GetDynamicCollection throws, because a dictionary record has no properties for the compiled filter to bind to, and a string search value throws, because this package generates no embeddings. LodestarVectorStoreCollection.HybridSearchAsync fuses a vector ranking with a BM25 ranking through reciprocal rank at k = 60, keeping in the keyword ranking only the documents the keywords matched.

Lodestar.Preprocessing — feature scaling

Python Library C# Differences
StandardScaler().fit(X) scikit-learn StandardScaler.Fit(samples, featureCount) Identical, the near-constant rule included, with one divergence: a NaN or an infinity is refused here naming samples, where the reference skips the NaN and refuses only the infinity — the rule the four scalers share, and the one PartialFit, Transform and InverseTransform apply too. scale_ is 1 wherever var <= n·eps·var + (n·mean·eps)², the two-pass error bound from Chan, Golub and LeVeque — not wherever the variance is zero. The corpus freezes the pair that separates the two readings (1e8 ± 1e-8 against 1e8 ± 1e-7). X is a row-major span here rather than a 2-D array, and n_samples_seen_ is SampleCount.
scaler.transform(X) scikit-learn StandardScaler.Transform(samples) Identical, the refusal of a non-finite value included. Never writes to the input; scikit-learn's copy=False has no counterpart, since a span the caller owns is not this package's to overwrite.
scaler.inverse_transform(X) scikit-learn StandardScaler.InverseTransform(samples) Identical, the refusal of a non-finite value included.
scaler.mean_, var_, scale_ scikit-learn Mean, Variance, Scale Identical, None included: each is nullable and null exactly where the reference reports None. with_mean=False still fits a mean; only turning both steps off drops it.
with_mean=, with_std= scikit-learn StandardScalerOptions Both, same defaults.
StandardScaler().partial_fit(X) scikit-learn StandardScaler.PartialFit(samples) Identical statistics, and the same ones a single fit over the concatenated batches gives — to a couple of units in the last place, which is what the reference's own two paths differ by (3.3e-16 measured). The update is _incremental_mean_and_var's, correction term included. Two divergences of spelling: it returns a new scaler where partial_fit mutates and returns self, since every fitted object here is immutable; and SampleCount stays one number where n_samples_seen_ becomes a per-feature array once a NaN appears, which these scalers refuse. MinMaxScaler and MaxAbsScaler have it too; RobustScaler does not, as the reference does not — a median is not updatable from a summary.
StandardScaler(with_mean=False).fit(sparse) scikit-learn StandardScaler.Fit(CsrMatrix, options) Identical, and centring is refused exactly as the reference refuses it: subtracting a mean makes every absent zero a stored value. MaxAbsScaler takes a CsrMatrix outright and RobustScaler with centring off; MinMaxScaler has no sparse overload at all, where the reference raises at run time — the same refusal, moved to compile time. The absent zeros count toward every statistic: a variance is E[x²] − E[x]² over every row, and a mostly-zero column's quartiles are usually zero. The edge this needs is decision 0003. One divergence on a column stored twice in one row: all three sum its entries first, as scipy.sparse's reductions do after sum_duplicates(), so MaxAbsScaler agrees with the reference — which reaches scipy — while the reference's own StandardScaler reads each entry on its own and its RobustScaler raises. The finite check reads those sums too, so two stored 1e308 in one cell are refused as the dense overload refuses their +∞, where the reference checks each entry and fits scale_ = [inf] or [nan].
scaler.transform(sparse), scaler.inverse_transform(sparse) scikit-learn MaxAbsScaler.Transform(CsrMatrix), StandardScaler.Transform(CsrMatrix), RobustScaler.Transform(CsrMatrix) and their InverseTransform overloads Identical: every stored value multiplied by 1 / scale_ there and by scale_ back, as inplace_column_scale does, and the stored positions unchanged; MaxAbsScaler clips the stored values when asked. One divergence on a column stored twice in one row: clipping clamps the cell's sum and stores it once, as the dense overload reads it, where the reference clamps each entry — 2 and 2 over a scale of 1.6 read 2 there and 1 here; the finite check reads those sums as the fits' does. A StandardScaler that centres is refused, as the reference refuses it, and so is a matrix with no row. One divergence on a value no statistic can answer for: a stored NaN is refused here for all three scalers where the reference passes it through (ensure_all_finite="allow-nan"). A stored infinity is refused on both sides. One divergence: a RobustScaler fitted densely with centring on skips the centring on a sparse matrix in the reference, without a word; this refuses it, since the result would not be centred.
KFold(n_splits=k).split(X) scikit-learn Splitters.KFold(sampleCount, foldCount) Identical, fold for fold and index for index, the remainder included: the first n % k folds take one extra row. Row counts in, indices out — the splitter never sees X.
StratifiedKFold(n_splits=k).split(X, y) scikit-learn Splitters.StratifiedKFold(labels, foldCount) Identical, including the two rules a reimplementation gets wrong: fold i takes the sorted labels at positions i, i + k, …, so a class of two over three folds lands in folds 0 and 2; and classes are numbered by first appearance, not by label value. One divergence: where a fold count above the smallest class count makes the reference warn, this refuses nothing and says so on the page — a warning is not a return value. It is still refused when the count is above every class.
train_test_split(X, test_size=f, shuffle=False) scikit-learn Splitters.TrainTest(sampleCount, testFraction) Identical, ceil(n·f) rounding included. test_size as an integer count has no counterpart: the fraction is the form the reference documents first, and a caller holding a count can divide.
shuffle=True, random_state=s scikit-learn order, the third argument of each splitter Divergence by design: the permutation is an input, not a seed, since random_state reaches numpy's generator and .NET has no copy of it. Pass the permutation scikit-learn drew and KFold(shuffle=True) and ShuffleSplit match, the train/test split holding out the permutation's head as ShuffleSplit does; pass your own and the split is still exactly reproducible. StratifiedKFold(shuffle=True) shuffles each class's fold list, not the rows, so no permutation reproduces it: its order gives the unshuffled folds over y[order] (decision 0004).
train_test_split(..., stratify=y) scikit-learn — (not written) The reference itself refuses stratify with shuffle=False. With a permutation supplied the honest form is StratifiedKFold at foldCount = round(1 / testFraction), taking one fold as the test set, which the page says.
GroupKFold, TimeSeriesSplit, RepeatedKFold scikit-learn — (not written) No caller yet (decision 0003); grouped and time-ordered splits are each their own parity claim, not a flag on these.
MinMaxScaler(feature_range, clip).fit(X) scikit-learn MinMaxScaler.Fit(samples, featureCount, options) Identical, data_min_, data_max_, data_range_, scale_ and min_ included. The near-constant rule is range < 10·eps, which is _handle_zeros_in_scale with no constant mask — not range == 0, and not StandardScaler's two-pass variance bound either. feature_range is the Low/High pair.
MaxAbsScaler(clip).fit(X) scikit-learn MaxAbsScaler.Fit(samples, featureCount, options) Identical, max_abs_ and scale_ included, the same near-constant rule. clip is a 1.9.1 member and is carried.
RobustScaler(with_centering, with_scaling, quantile_range).fit(X) scikit-learn RobustScaler.Fit(samples, featureCount, options) Identical. center_ is numpy.median and the range is numpy.percentile's linear interpolation (Hyndman–Fan type 7), which is neither of the two quantile conventions already here — SplitConformal.Quantile takes the conformal order statistic and Lodestar.Metrics averages a weighted percentile. Each switch leaves exactly its own statistic null, as the reference reports None.
scaler.transform(X), inverse_transform(X) on the three scikit-learn Transform, InverseTransform Identical, including that clip applies to the transform and never to its inverse. Never writes to the input; copy=False has no counterpart, since a span the caller owns is not this package's to overwrite.
RobustScaler(unit_variance=True) scikit-learn RobustScalerOptions.UnitVariance Identical, the order of the two scale rules included: the range is floored to 1 first and divided by Φ⁻¹(q_max/100) − Φ⁻¹(q_min/100) second, so a constant feature lands on 1/1.3489795. The quantile is Lodestar.Stats' published one, the edge decision 0003 took. One divergence: a percentile of 0 or 100 is refused here, having no finite quantile, where the reference divides by an infinity and reports a scale of zero.
MinMaxScaler().fit(X) with a NaN scikit-learn — (refused) The reference skips a NaN (nanmin, nanmedian, nanpercentile); all three refuse a non-finite value, because RobustScaler sorts and a NaN in a sorted column returns a percentile nobody asked for. StandardScaler predates the rule and propagates instead.
OneHotEncoder(drop, handle_unknown).fit(X) scikit-learn Encoders.OneHot(values, featureCount, options) Identical: categories_ sorted, one column per category in that order, the features' blocks in order. drop="first" and "if_binary" both carried, the second dropping only where a feature has exactly two categories. An ignored unknown encodes to all zeros, the same row a dropped first category gives — the reference's collision, reproduced.
OrdinalEncoder().fit(X) scikit-learn Encoders.Ordinal(values, featureCount) Identical, the code being the index into the sorted categories. handle_unknown="use_encoded_value" has no counterpart: it needs a value outside the codes, and nothing here asks for one yet (decision 0003's rule); an unseen category is refused, as the reference's own default does.
Category order numpy Categories A string sorts by code point, which is numpy's order and not .NET's culture-sensitive default: ['B', 'a', 'b', 'A'] gives A, B, a, b rather than a, A, b, B, and the two produce different columns for the same data. An integer column sorts as numbers, which is why the encoders are generic rather than taking everything as text.
SimpleImputer(strategy, fill_value).fit(X) scikit-learn SimpleImputer.Fit(samples, featureCount, options) Identical on all four strategies, NaN as the missing marker. A most_frequent tie goes to the smaller value, measured; the median of an even count is the average of the two middle values, numpy.median's own.
SimpleImputer(keep_empty_features=False) scikit-learn — (refused) Divergence. A feature with no value at all has no statistic, and the reference drops it, returning a matrix one column narrower than the one it was given. This refuses it instead, naming the feature: a transform whose output width depends on the fitted data rather than on the input shape is a trap in a typed API. KeepEmptyFeatures gives the reference's True behaviour, filling with zero, or with FillValue under Constant.
OneHotEncoder(sparse_output=True), min_frequency, max_categories scikit-learn — (not written) Sparse output would return a CsrMatrix; #765 gave the scalers sparse input and left the encoder's output as it was. Neither it nor infrequent-category grouping has a caller yet (decision 0003).
imblearn.over_sampling.SMOTE imbalanced-learn — (not written) Nothing in .NET: the one NuGet package wraps CNTK. Not written anyway, because SMOTE draws its neighbours and gaps from random_state with no parameter to hand them in, so no run can be replayed from .NET. It waits for a caller (decision 0004).

Lodestar.Cluster — k-means

Python Library C# Differences
KMeans(n_clusters=k, init=C, n_init=1, algorithm="lloyd").fit(X) scikit-learn KMeans.Fit(samples, featureCount, clusterCount) Identical over a given init, the final assignment and the empty-cluster relocation included — several clusters emptied at once each take a distinct furthest sample, as _relocate_empty_clusters_dense gives them. tol is scaled by the mean feature variance on both sides, and refused outside [0, inf) on both — ArgumentOutOfRangeException here. One measured divergence: a sample exactly equidistant from two centres takes the lowest-indexed one here, where the reference's choice was observed going both ways (decision 0007); samples equally far from their centres are relocated lowest row first, where the reference follows numpy.argpartition, whose choice among them changes with the CPU tier numpy dispatches to — so with tied furthest distances the centres and inertia_ can differ, from each other as from here. No frozen case turns on a tie. A NaN or infinite sample or starting centre raises ArgumentException where the reference raises ValueError.
init= as an array scikit-learn KMeansOptions.InitialCentres Identical, and it is what makes the row above a parity target rather than a distribution — the same move decision 0004 made for Ω.
init="k-means++", random_state= scikit-learn KMeansOptions.Seed Not comparable. k-means++ runs here over this package's own generator; a seed reproduces a run of Lodestar and never a run of scikit-learn. Pass InitialCentres to compare against Python.
kmeans.cluster_centers_, labels_, inertia_, n_iter_ scikit-learn Centres, Labels, Inertia, Iterations Identical. Centres is row-major rather than 2-D.
kmeans.predict(X_new) scikit-learn KMeans.Predict(samples) Identical: the assignment step alone, over the fitted centres. A NaN or infinite sample raises ArgumentException, the reference's ValueError.
n_init= above 1 scikit-learn — (no counterpart) Restarting from several draws is a loop over the choice, and the choice is an input here. A caller wanting it runs Fit per starting block and keeps the lowest Inertia.
algorithm="elkan", sample_weight= scikit-learn — (no counterpart) Elkan's variant reaches the same partition by fewer distance computations; it is an optimisation, not a different answer, and out of scope for 0.1.0.
DBSCAN(eps=, min_samples=) scikit-learn Dbscan.Fit(samples, featureCount, epsilon, minimumSamples) Identical labels, compared exactly rather than to a tolerance. eps is inclusive on both sides and min_samples counts the sample itself on both. Neither is defaulted here: scikit-learn's eps=0.5 is meaningful only on scaled data. A NaN or infinite sample raises ArgumentException where the reference raises ValueError.
dbscan.labels_, core_sample_indices_ scikit-learn Labels, CoreSampleIndices Identical, noise spelled -1 on both. ClusterCount has no counterpart: scikit-learn leaves the caller to count the distinct non-negative labels.
a border sample two clusters reach scikit-learn same cluster Identical, and order-dependent on both sides. It joins whichever cluster is grown first, which is the one whose lowest-indexed core sample comes first — measured on four orderings of one point set. Sorting a matrix can change it; nothing else here depends on row order.
DBSCAN(metric="precomputed") scikit-learn Dbscan.FitPrecomputed(distances, sampleCount, epsilon, minimumSamples) Identical. Named rather than an overload: both entry points take a row-major span and an int, so an overload pair would differ only in what the second argument means. A NaN or infinite distance raises ArgumentException, as the reference raises ValueError on both.
DBSCAN(metric=) beyond euclidean, sample_weight=, algorithm= scikit-learn — (no counterpart) Each metric is its own parity target with its own corpus — cosine disagrees with euclidean on the first fixture probed. The ball-tree and kd-tree searches are an optimisation, not a different answer.
AgglomerativeClustering(n_clusters=, linkage=) scikit-learn AgglomerativeClustering.Fit(samples, featureCount, clusterCount, linkage) Identical labels_ and children_, compared exactly, and distances_ at 1e-9 — ties included, under all four linkages. ward is the default on both sides. A NaN or infinite sample raises ArgumentException where the reference raises ValueError.
AgglomerativeClustering(n_clusters=None, distance_threshold=) scikit-learn AgglomerativeClustering.FitToThreshold(samples, featureCount, distanceThreshold, linkage) Identical. Exclusive on both sides: a merge exactly at the threshold is not made. Zero is allowed and infinity refused, the reference's [0, inf). Two named methods replace the reference's run-time check that exactly one of the two is set. A NaN or infinite sample raises ArgumentException, as under Fit.
labels_, children_, distances_, n_clusters_ scikit-learn Labels, Children, Distances, ClusterCount Identical. Children is row-major pairs rather than a 2-D array. Distances is always populated, where the reference fills it only under compute_distances=True: it is n − 1 doubles beside a quadratic distance matrix. Labels follow the reference's heap order, not first appearance, on both sides.
linkage="single" against the other three scikit-learn Linkage.Single Identical, and a different algorithm on both sides: scikit-learn runs its own minimum spanning tree for single linkage and scipy's nearest-neighbour chain for the rest, and they order a merge's pair differently — [7, 4] against [4, 7]. Both layouts are reproduced.
connectivity=, metric= beyond euclidean, pooling_func=, sparse input scikit-learn — (no counterpart) Out of scope for #760. compute_full_tree= has no counterpart either: without connectivity the reference always builds the whole tree, and so does this.
GaussianMixture, HDBSCAN scikit-learn — (delegated) NumFlat's GaussianMixtureModel on net8.0 and above, and HdbscanSharp on any target. Neither has been compared with scikit-learn (decision 0004).
MiniBatchKMeans scikit-learn — (not written) Its batches are drawn inside the fit from random_state and no parameter hands them in, so no run can be replayed from .NET and there is nothing to hold a corpus to (decision 0004).
SpectralClustering scikit-learn — (not written) Needs the leading eigenvectors of an affinity Laplacian, ARPACK by default; nothing in Lodestar.Decomposition computes them. Stays out until an eigensolver exists, not beside the rows above as an equal (decision 0004).

Lodestar.Stats — distribution tails

Published for a caller holding its own statistic, rather than handing this package its groups. Decision 0003 records why these three and no more.

Python Library C# Differences
scipy.stats.t.sf(t, df) scipy Distributions.StudentSf(t, df) Identical, into the far tail: the corpus reaches 3.1e-24 and is compared relatively. df of NaN is refused rather than propagated.
scipy.stats.t.ppf(p, df) scipy Distributions.StudentQuantile(p, df) Identical. The endpoints are refused rather than answered with the two infinities, which scipy returns.
scipy.stats.f.sf(f, dfn, dfd) scipy Distributions.FisherSf(f, dfn, dfd) Identical. One divergence, where scipy is the one off: against exact binomial sums at integer dfn / 2, scipy's tail moves by up to 5e-12 relative at dfd = 2e5, 5e-9 at 2e8 and 5.5e-8 at 2e9, about what rounding dfd / (dfd + dfn·f) to a double costs the complement it depends on. Both halves of that argument are formed by division here, and matched the sums to 1e-13 (#841).
scipy.stats.chi2.sf(x, df) scipy Distributions.ChiSquaredSf(x, df) The upper tail of the chi-squared distribution, published for a log-rank test (decision 0003). Compared relatively, reaching 7.7e-26. df must be positive; x is not validated and a non-positive statistic returns one, because the distribution has no mass below zero. One divergence, where scipy is the one off: past about df = 5e6 and more than 4.5 standard deviations below the mean, gammaincc leaves Temme's expansion for a series it stops at 2,000 terms, and moves by up to 2e-6 relative (df = 1e9, eight deviations down); the expansion is kept there, and matched the uncut series to 1e-12 (#837).
scipy.stats.norm.ppf(p) scipy Distributions.NormalQuantile(p) The standard normal quantile, published for the large-sample confidence bounds a Kaplan-Meier curve carries (decision 0003). The quantile, not the inverse survival function — the sign is the distribution's symmetry, as for StudentQuantile. The median returns positive zero. Compared relatively.
scipy.stats.t.isf(p, df), t.cdf, f.cdf, norm.*, chi2.* scipy — (no counterpart) Only what one caller needed is published. isf(p, df) is -StudentQuantile(p, df) by symmetry; the rest stay internal until something asks.

Lodestar.Stats.TimeSeries — serial-correlation diagnostics

Oracled against statsmodels 0.15.0, already in the lock since #566 (#617).

Python Library C# Differences
acf(x, nlags=m, adjusted=False, fft=True, alpha=0.05, bartlett_confint=True) statsmodels SerialCorrelation.Autocorrelation(series, lagCount, options) lagCount is required, where the reference defaults nlags — and to two different rules depending on the function: min(10·log10(n), n - 1) here, min(10·log10(n), n/2 - 1) for pacf below. Pass either deliberately to reproduce a reference plot. The autocovariance is a direct double sum, where acf defaults to fft=True. Measured directly against statsmodels on this branch's own fixtures (tools/generate_oracles.py's _timeseries_fixtures): the largest gap between the direct sum and the FFT path is 4.44e-16 — an ordering difference in how the same sum is accumulated, not a definitional one, well inside the 1e-9 this corpus compares at. adjusted is AutocorrelationOptions.Adjusted, bartlett_confint is .BartlettConfidenceInterval, and alpha is 1 - .ConfidenceLevel. A constant series is refused. The reference returns NaN with a warning — avf[0] is zero, so every ratio would be 0/0 — and this throws instead, as KruskalWallis.Test already refuses a fully tied pooled sample for the same reason.
values, confint = acf(...) statsmodels AutocorrelationResult.Values, .ConfidenceLower, .ConfidenceUpper Identical, compared at 1e-9. Two parallel lists rather than an n × 2 array for the band. One divergence worth its own note: with bartlett_confint=False the reference's non-Bartlett variance is a scalar applied uniformly, lag zero included, so confint[0] is 1 ± z·sqrt(1/n) rather than the point [1, 1] Bartlett's own formula gives there by construction — surfaced while replaying the corpus, and now its own test.
pacf(x, nlags=m, method="ywadjusted", alpha=0.05) statsmodels SerialCorrelation.PartialAutocorrelation(series, lagCount, options) lagCount is required here too, at the reference's other default rule, min(10·log10(n), n/2 - 1). ywadjusted (also spelled yw, ywa, yw_adjusted) is the reference's own default and the only method shipped; the reference offers seven other estimators — ywm, ols, ols-inefficient, ols-adjusted, ld, ldbiased, burg — across four families. Solved by the Levinson-Durbin recursion rather than the reference's per-order Yule-Walker solve, which is quartic in the order; the two agree because solving order k from order k - 1 is the same recursion. Only AutocorrelationOptions.ConfidenceLevel is read; Adjusted and BartlettConfidenceInterval do not apply.
acorr_ljungbox(x, lags=range(1, m + 1), model_df=0, boxpierce=True) statsmodels SerialCorrelation.LjungBox(series, lagCount, options) Identical, compared relatively. model_df is LjungBoxOptions.ModelDegreesOfFreedom, boxpierce is .BoxPierce. A lag whose degrees of freedom reach zero or below answers NaN in the p-value on both sides rather than throwing; only a negative ModelDegreesOfFreedom itself is refused, matching the reference's own ValueError.
frame["lb_stat"], frame["lb_pvalue"], frame["bp_stat"], frame["bp_pvalue"] statsmodels LjungBoxResult.Statistics, .PValues, .BoxPierceStatistics, .BoxPiercePValues Identical, compared relatively — the corpus reaches 1e-9. One addition: .DegreesOfFreedom reports the lag less the model's parameters explicitly at every index, where the reference leaves it implicit in lag - model_df. BoxPierceStatistics and BoxPiercePValues are empty lists rather than lists of NaN when boxpierce was not asked for.

Lodestar.Stats.TimeSeries — stationarity

Python Library C# Differences
adfuller(x, maxlag=None, regression="c", autolag="AIC") statsmodels Stationarity.AugmentedDickeyFuller(series, options) Identical statistic, p-value, lag, observation count, critical values and icbest, every regression and autolag. The regressions are solved by Lodestar.Stats.Regression's Householder reflections, the ones OrdinaryLeastSquares.Fit falls back to, where the reference uses a pseudo-inverse: an ordering difference, well inside the corpus's 1e-9. A constant series is refused where the reference raises. A series lying on one straight line is refused naming series, at every trend specification and every lag: above lag zero its lagged differences repeat the intercept and the design has no unique solution, and at lag zero under regression="c" the design is full rank but the fit exact, so the statistic is one rounding error over another (0.1475 on 0.3 + 0.1·i, 40 points). Under regression="n" at lag zero alone the design is full rank and the fit is not exact, and the reference's 12.0499 on that same line is refused here too: a line is one input, and a reader holds one contract for it rather than seven refusals and a number. The reference's pseudo-inverse answers the minimum-norm fit in the first case and that rounding in the second (#979, #1080). The lag search is held to the same contract: a candidate design with no unique solution is refused naming series, where the reference warns SingularMatrixWarning and ranks the candidates on their minimum-norm fits (1.7802 at lag 3 on 1, 2, …, 50 with its first point moved to −5, whose differences over the search's rows are all 1) (#977). A maximum lag that leaves the widest regression no degree of freedom, reached by default below about 22 observations under regression="n", is refused naming series or options; the reference returns a rank-deficient answer with a warning.
regression=, autolag=, maxlag= statsmodels DickeyFullerOptions TrendTerms for "n", "c", "ct", "ctt"; LagSelection for "AIC", "BIC", "t-stat", None.
store=, regresults= statsmodels — (no counterpart) The intermediate regressions are not returned.
kpss(x, regression="c", nlags="auto") statsmodels Stationarity.Kpss(series, options) Identical statistic, p-value, lag window and critical values. The InterpolationWarning is a property: KpssResult.PValueBound says the returned p-value is the table's end and which way the truth lies. The ct residuals come from a closed-form line rather than OLS, agreeing at the corpus's tolerance. A series lying on a line under ct is refused with ArgumentException, as a constant one is; statsmodels returns a statistic from its fit's rounding noise (2.8957 on 1, 2, …, 30). The line is measured twice and refused only when both agree: its least-squares residuals within n·ε of the largest observation, which caught 100 of 100 random lines where a test for exact zeros caught 1 (#976), and its largest second difference within eight ulps of it, which is what keeps a million points wobbling by 1e-10 and 50,000 on a line missed by 1e-7 answered (#1080). 300 random series that are not lines agree with statsmodels to 3.3e-13 on the statistic, with the same window.

Lodestar.Stats.TimeSeries — seasonal decomposition

Python Library C# Differences
seasonal_decompose(x, model="additive", period=p, two_sided=True, extrapolate_trend=0) statsmodels SeasonalDecomposition.Decompose(series, period, options) Identical trend, seasonal and residual, NaN positions included. period is required: the reference infers it from a pandas index. A period below two is refused. The extrapolation's back window leaves the last defined point out, as the reference's does.
filt= statsmodels — (no counterpart) Only the default moving-average filter.
extrapolate_trend="freq" / "period" statsmodels ExtrapolateTrend = period - 1 The string spellings are the integer the reference turns them into.
STL statsmodels — (not written) Loess-based, a different algorithm; stlnet ships it under MIT.

Lodestar.Stats.TimeSeries — model estimation

Python Library C# Differences
ARIMA(y, order=(p, d, q)).fit(), SARIMAX(...).fit() statsmodels — (not written) Decision 0004: a likelihood optimisation whose own solvers disagree at 1e-4 on one series (state space against innovations MLE, L-BFGS against Nelder–Mead), so no corpus can pin it at 1e-9. Cortex.TimeSeries' ARIMA is not a delegate: its coefficients are a pure autoregression's least squares. Forecasting stays with Microsoft.ML.TimeSeries (decision 0004).
UnobservedComponents, DynamicFactor, other state-space models statsmodels — (not written) The same likelihood, optimised the same way; decision 0004.
VAR(y).fit(p) statsmodels VectorAutoregression.Fit(series, variableCount, lagOrder, options) Identical over 4 frozen cases: least squares equation by equation on the stacked lags, sigma_u = S/(T − k) and sigma_u_mle = S/T, the Gaussian log-likelihood, and AIC, BIC, HQIC and FPE read off the maximum-likelihood covariance with df_model·K free parameters — so a fit without a constant counts K fewer. The tests read the normal, as the reference's do. No intervals: VARResults publishes none, and none are invented here. trend="ct" and "ctt", exogenous regressors, select_order, impulse responses and test_causality have no counterpart yet. One divergence: a collinear lagged design is refused, as OrdinaryLeastSquares.Fit refuses one; the reference's least squares answers with the minimum-norm fit and standard errors near 1e6 on two variables where one is 1.5 times the other.

Lodestar.Decomposition — QR

Python Library C# Differences
numpy.linalg.qr(A, mode="reduced") numpy QrDecomposition.Householder(matrix, rowCount, columnCount) Same thin shape. The signs are not normalised on either side: a QR is unique only up to the sign of each column, so compare Q · R, or column by column up to sign, rather than entry for entry. A wide matrix is refused here where numpy returns a factorization of a different shape.
scipy.linalg.qr, numpy.linalg.qr(mode="complete"), pivoting numpy, scipy — (no counterpart) The full factorization, column pivoting and the raw mode are out of scope: the thin one is what a least-squares solve wants.

Lodestar.Stats.Regression — ordinary least squares

Python Library C# Differences
sm.OLS(y, sm.add_constant(X)).fit() statsmodels OrdinaryLeastSquares.Fit(design, response, featureCount) Identical over the whole table on a design of full rank. X is a row-major span here rather than a 2-D array, and the constant column is not supplied: OlsOptions.WithIntercept prepends it, so Coefficients[0] is the intercept. Solved through the normal equations when an upper bound on the column-scaled design's condition number stays within 200, where they lose about 200²·ε, and through Householder reflections otherwise, where the reference's default method="pinv" takes a pseudo-inverse: an ordering difference, inside the corpus's 1e-9. One divergence: a collinear design is refused — a design whose smallest singular value falls at or below σmax·max(n, p)·ε, numpy.linalg.matrix_rank's own tolerance — R carries the design's singular values unchanged, the reflections being orthogonal, so the two refuse the same designs. Like the reference's, this test is not scale invariant: a column at 1e-14 beside one at 1e8 is rank 2 to matrix_rank and refused here, where the per-column pivot it replaced accepted it and also missed a dependent column far smaller than its sources (#978). The reference's pseudo-inverse answers with the minimum-norm fit and df_resid = n − rank: on x = 1..8 with x₂ = 3·x₁, y = [2.1, 3.9, 6.2, 7.8, 10.1, 12.2, 13.8, 16.1] and an intercept it reports params [0.0357, 0.1998, 0.5993] and df_resid 6, one of infinitely many estimates that fit equally well. Drop the dependent regressor.
.params, .bse, .tvalues, .pvalues statsmodels OlsSummary.Coefficients, .StandardErrors, .TStatistics, .PValues Identical, compared relatively — the corpus reaches 2.9e-11, where an absolute tolerance would assert only that a number came back (decision 0003).
.params, .bse, .tvalues, .ssr alone statsmodels OrdinaryLeastSquares.Estimate(design, response, featureCount, withIntercept)OlsEstimate Identical to Fit on the numbers it keeps, over the same corpus; the non-robust standard errors only. A rank-deficient or collinear design is refused here as it is there, which it was not until #979 — on 400 random well-conditioned designs the estimates agree with statsmodels 0.15.0 to 2.3e-13 relative, and 120 random rank-deficient ones are all refused where they were answered with coefficients up to 1.3e16. No statsmodels call stops here — fit() computes the whole table — so this is Fit without what a caller fitting many regressions never reads.
.conf_int(alpha=0.05) statsmodels OlsSummary.ConfidenceLower and .ConfidenceUpper Identical. Stated as a level rather than an alpha: ConfidenceLevel = 0.95 is alpha=0.05. Two parallel lists rather than an n × 2 array.
.rsquared, .rsquared_adj statsmodels OlsSummary.RSquared, .AdjustedRSquared Identical, the uncentred form included: with no intercept both sides score against zero rather than against the response's mean, and adjust against n rather than n - 1.
.fvalue, .f_pvalue statsmodels OlsSummary.FStatistic, .FPValue Identical, the degree of freedom a missing intercept adds included. An exact fit leaves no residual variance and both sides report an infinite F; that case is pinned by a test rather than by the corpus, since JSON cannot write an infinity.
numpy.sqrt(.mse_resid), .df_resid statsmodels OlsSummary.ResidualStandardError, .ResidualDegreesOfFreedom Identical. A design leaving no residual degrees of freedom is refused here rather than answered with zeros.
variance_inflation_factor(exog, i) statsmodels OlsSummary.VarianceInflationFactors Identical, including the standardisation 0.15.0 added: each column is centred and scaled to unit spread before the auxiliary regression, exempting a column whose spread is at or below 1e-10, and the R-squared is clipped to 1 - 1e-15. That is a no-op with an intercept and the whole answer without one — 87.43 on the naive reading against the reference's 21.0. One entry per regressor here, so the list is shorter than Coefficients by one when an intercept was fitted. One divergence: a single regressor with no intercept leaves the auxiliary design empty, where statsmodels raises a ValueError out of numpy and this reports NaN — one undefined diagnostic does not sink a fit that is otherwise sound (decision 0003).
.summary() statsmodels — (no counterpart) The formatted text block is a presentation concern; every number in it is a property of OlsSummary.
sm.OLS(y, X).fit(cov_type="HC0".."HC3") statsmodels OrdinaryLeastSquares.Fit(design, response, featureCount, new OlsOptions { CovarianceType = CovarianceType.Hc3 }) The four heteroskedasticity-consistent estimators, spelled as an enum rather than a string. The distribution moves with it, as it does in statsmodels: TStatistics and PValues become z against the normal, the interval multiplier with them, while FPValue stays on the F — the asymmetry is reproduced rather than tidied (decision 0004). OlsSummary.CovarianceType echoes which was used. A leverage of exactly one leaves HC2 and HC3 dividing by zero and both sides return infinity.
sm.OLS(y, X).fit(cov_type="HAC", cov_kwds={"maxlags": L}) statsmodels OrdinaryLeastSquares.Fit(design, response, featureCount, new OlsOptions { CovarianceType = CovarianceType.Hac, HacLags = L }) Identical over the whole table, over 3 frozen cases: Bartlett weights 1 - l/(L+1) on the scores xᵢrᵢ in row order, use_correction as SmallSampleCorrection (off by default, n/(n-k) when on), coefficients on the normal and the F on n - k. maxlags at or past the row count is accepted on both sides and still sets the weights. Divergences: a missing HacLags is refused where the reference raises KeyError, and a negative one where it raises IndexError; weights_func other than Bartlett, hac-panel and hac-groupsum have no counterpart.
sm.OLS(y, X).fit(cov_type="cluster", cov_kwds={"groups": g}) statsmodels OrdinaryLeastSquares.Fit(design, response, clusters, featureCount, new OlsOptions { CovarianceType = CovarianceType.Cluster }) Identical over the whole table, over 3 frozen cases: the sum over clusters of each cluster's summed scores, use_correction on by default as G/(G-1)·(n-1)/(n-k), coefficients on the normal, and the F test on G - 1 denominator degrees of freedom (df_resid_inference) while ResidualDegreesOfFreedom stays n - k. The reference's f_pvalue depends on which attribute is read first: fvalue caches the p-value of its f_test on G - 1, and f_pvalue read before it computes stats.f.sf(F, df_model, df_resid) on n - k — measured on the reference page's example, 0.00046 against 1.92e-12. This follows the first, which is what df_resid_inference exists for, and the corpus reads fvalue first. Labels are any int, in any order. Divergences: a single cluster is refused where the reference raises ZeroDivisionError; a negative label is accepted where the reference's np.bincount refuses one on int64 labels (it relabels any other dtype through np.unique, which the result here equals). Two-way clusters, cluster-crv3, cluster-jk and df_correction=False have no counterpart.
sm.WLS(y, sm.add_constant(X), weights=w).fit() statsmodels WeightedLeastSquares.Fit(design, response, weights, featureCount) Identical over the whole OlsSummary table, cov_type="HC0".."HC3", "HAC" and "cluster" included, over 14 frozen cases — the cluster labels through WeightedLeastSquares.Fit(design, response, weights, clusters, featureCount, options): rows and response scaled by √w, R² centred on the weighted mean (or Σ w·y² with no intercept), the model's own constant kept out of the robust Wald test, and a zero weight kept in df_resid. Three divergences. Refused weights: a negative, NaN or infinite weight throws here, where the reference fails inside its SVD or propagates NaN; and fewer positively weighted rows than parameters — all-zero weights the extreme of it — throws where the reference answers with the minimum-norm solution through its pseudo-inverse, and so does a collinear weighted design, as on OrdinaryLeastSquares.Fit. The VIFs are variance_inflation_factor(exog, i) on the design as given, since WLS reports none and that function takes no weights.
sm.GLS(y, sm.add_constant(X), sigma=Σ).fit() statsmodels GeneralizedLeastSquares.Fit(design, response, covariance, featureCount) Identical over the whole OlsSummary table, cov_type="HC0".."HC3" included, over 9 frozen cases: AR(1) and block covariances, rows whitened by L⁻¹ from the lower Cholesky factor, R² centred on the whitened-space mean, the model's constant kept out of the robust Wald test. L⁻¹ is applied by forward substitution where the reference forms it with dtrtri: rounding only. sigma as a vector maps to WeightedLeastSquares.Fit with weights 1/σ — measured, the reference's two agree to 2.7e-15 on the estimates — and a scalar sigma to OrdinaryLeastSquares.Fit. Divergences: an asymmetric covariance (mirrored entries more than 1e-12 apart, relatively) is refused, where the reference's Cholesky reads the lower triangle and ignores the rest; a non-finite entry is refused; a covariance that is not positive definite is refused as the reference raises LinAlgError, with a pivot floor of 1e-12 of its diagonal; a collinear design is refused where the reference's pseudo-inverse answers, as on OrdinaryLeastSquares.Fit; the VIFs read the design as given; cov_type="HAC" and "cluster" are refused, which no corpus covers on whitened GLS rows. GLSAR and any feasible GLS that estimates Σ have no counterpart (#771's spec).

Lodestar.Stats.Regression — multinomial and ordinal responses

Python Library C# Differences
MNLogit(y, X).fit() statsmodels MultinomialLogit.Fit(design, response, featureCount, options) Identical over 5 frozen cases: Newton from zeros with the reference's 1e-10 ridge, tol=1e-8 and 35-step budget and its iteration count, analytic standard errors, df_model = (K − 1)(J − 1) with or without a constant, labels sorted by value. Two divergences, both from the null model and a separated response (decision 0004): llnull is the closed form Σ nⱼ log(nⱼ/n), which the reference approximates by a Nelder–Mead and BFGS refit to within 3e-10, so the likelihood-ratio p-value differs by up to 1.3e-8 (scipy's chi2.sf on the closed-form statistic matches the C# at 1e-9); a perfectly separated response is refused where the reference returns NaN coefficients with converged=True.
OrderedModel(y, X, distr="logit" | "probit").fit() statsmodels — (not written) Numerical score and Hessian; its default Nelder–Mead stops 2e-4 from the maximum, and even Newton refitted from its own optimum moves the errors at 6e-7. No corpus can pin it at 1e-9; decision 0004.

Lodestar.Stats.Regression — regularised fits

Python Library C# Differences
GLM(y, X, family=f).fit_regularized(alpha=a, L1_wt=w) statsmodels — (delegated to Microsoft.ML) Decision 0004: the reference returns coefficients and no inference table — reading bse on its RegularizedResults raises AttributeError — so there is no table for this package to add. ML.NET's LBFGS trainers reach the same optimum, measured identical to six decimals with the penalty scaled by n, selected variables and their zeros included; their weights are float, 1.8e-5 from the reference on a ridge fit.
GLM(...).fit_regularized(refit=True) statsmodels OrdinaryLeastSquares.Fit or GeneralizedLinearModel.Fit on the non-zero columns The same computation: an unpenalised fit on the variables the penalty kept. The reference's warning travels with it — selection and inference read the same rows.

Lodestar.Stats.Regression — instrumental variables and panel data

Python Library C# Differences
IV2SLS(y, exog, endog, instruments).fit(), IVLIML, IVGMM (two-step) linearmodels — (no counterpart yet) Decision 0004: closed forms the reference reproduces at 1e-15 against direct computation — 2SLS with s² = eᵀe/n by default and eᵀe/(n − k) when debiased, LIML's κ from the eigenvalue problem, the two-step heteroskedastic weight. statsmodels.sandbox's IV2SLS agrees with the debiased estimates to 3.7e-16. Waits for an issue.
IVGMM(...).fit(iter_limit=...), iterated linearmodels — (not written) Moves at 6.6e-6 between tol=1e-6 and 1e-12, so no corpus can pin it at 1e-9; decision 0004.
PanelOLS(..., entity_effects=True, time_effects=True).fit(), BetweenOLS, FirstDifferenceOLS, RandomEffects linearmodels — (no counterpart yet) Least squares on the within, mean or differenced rows, matched at 1e-15 on balanced and unbalanced panels. The errors carry linearmodels' factors: unadjusted fixed-effects errors are the within rows' scaled by √((n − k)/(n − k − N)), and entity-clustered errors measured 0.99306 of statsmodels' cluster correction on the same rows. Waits for an issue; decision 0004.

Lodestar.Stats.Regression — generalized linear models

Fitted by IRLS over the Householder reflections OrdinaryLeastSquares.Fit falls back to, never its normal equations (decision 0003).

Python Library C# Differences
sm.GLM(y, X, family=sm.families.Binomial()).fit(), sm.GLM(y, X, family=sm.families.Poisson()).fit() statsmodels GeneralizedLinearModel.Fit(design, response, featureCount, family) Identical over the whole table. X is a row-major span here rather than a 2-D array. The family argument is GlmFamily.Binomial or .Poisson — a closed enum rather than a family= object, so an internally inconsistent family cannot be constructed. Only these two links (logit, log) are wired for them; the negative binomial and Gamma have their own rows below, and the other families sm.families publishes (inverse Gaussian, Tweedie) have no counterpart yet.
sm.GLM(y, X, family=sm.families.NegativeBinomial(alpha=a)).fit() statsmodels GeneralizedLinearModel.Fit(design, response, featureCount, GlmFamily.NegativeBinomial, new GlmOptions { NegativeBinomialAlpha = a }) Identical over the whole table, 7 frozen cases: the default log link, variance μ + αμ², alpha given rather than estimated, scale fixed at one, deviance and log-likelihood term for term. The lnΓ(y + 1/α) the log-likelihood reads is an internal shift-and-Stirling series held to scipy.special.gammaln over (0, 1e6]. Divergences: an alpha left unset is the reference's default 1.0 without its ValueWarning; an alpha of zero, negative or non-finite is refused by the option where the reference divides by zero or fails its first deviance; an alpha given to Binomial or Poisson is refused rather than ignored; and a fractional count is refused, as Poisson's is, where the reference's gammaln fits it. sm.NegativeBinomial, the discrete model that estimates alpha, has no counterpart (#769's spec says why).
sm.GLM(y, X, family=sm.families.Gamma(link=...)).fit() statsmodels GeneralizedLinearModel.Fit(design, response, featureCount, GlmFamily.Gamma, new GlmOptions { Link = GlmLink.Log }) Identical over the whole table, 5 frozen cases, the InversePower default and Log: variance μ², the Pearson scale Σ(y−μ)²/μ²/df_resid as GlmSummary.Dispersion multiplying the covariance, the log-likelihood at that scale, and an AIC that does not count it. The IRLS criterion is the deviance divided by the previous iteration's scale, as _fit_irls compares it; the unscaled deviance stops one iteration off. Divergences: a response that is zero, negative or non-finite is refused, where the reference fits a zero to a log-likelihood of +inf; a Gamma mean the inverse link takes to zero or below is refused at that iteration, where the reference carries on with |μ| in its variance (on x = 0..7, y = [1, 1.2, 1.5, 2, 3, 6, 30, 2] it converges to a last-row mean of -5.64); the DomainWarning the inverse link raises has no counterpart; the identity link is not fitted here.
sm.GLM(y, X, family=f, offset=o, exposure=e).fit() statsmodels GeneralizedLinearModel.Fit(design, response, offset, exposure, featureCount, family, options) Identical over 7 frozen cases across the four families: offset + log(exposure) in the predictor, subtracted from the working response, the reference's start and criterion. The null deviance refits the intercept-only model with the same term whenever either is given, an offset of zeros included, as GLMResults.null does; the refit starts from the family's own mean where the reference starts from link(mean(y)), and both reach its fixed point within 3.3e-15. An empty span is "not given". Refused as the reference refuses (ValueError): exposure with a link other than log, a length that differs from the response, a non-finite offset, an exposure not finite and above zero.
.params, .bse, .tvalues, .pvalues statsmodels GlmSummary.Coefficients, .StandardErrors, .ZStatistics, .PValues Identical, compared relatively as the OLS table above is, and over the whole GLM table rather than these four fields alone — measured on this corpus the worst field reaches 3.4e-14, where an absolute tolerance would assert only that a number came back (decision 0003). statsmodels names the Wald statistic tvalues on every GLM result; it is a z statistic against the normal tail, which ZStatistics names directly.
.conf_int(alpha) statsmodels GlmSummary.ConfidenceLower and .ConfidenceUpper Identical. Stated as a level rather than an alpha, through GlmOptions.ConfidenceLevel: 0.95 is alpha=0.05. Two parallel lists rather than an n × 2 array.
.deviance, .null_deviance statsmodels GlmSummary.Deviance, .NullDeviance Identical. Both are twice the log-likelihood gap to a saturated fit — the fitted model and the intercept-only one, whichever was fitted, respectively.
.scale statsmodels GlmSummary.Dispersion Identical: fixed at 1 for Binomial, Poisson and NegativeBinomial, which have no free dispersion parameter here, and estimated for Gamma as Pearson's χ² over df_resid, the reference's default scale=None.
.llf, .aic statsmodels GlmSummary.LogLikelihood, .Akaike Identical. Akaike is 2k - 2 logL, k the parameter count ResidualDegreesOfFreedom is computed against.
.df_resid statsmodels GlmSummary.ResidualDegreesOfFreedom Identical on every design both sides accept, and the two definitions are not the same one: this is rows less parameters, where the reference is rows less the design's rank. They part only on a rank-deficient design, which the row below refuses rather than answers. A design leaving no residual degree of freedom is refused here rather than answered with zeros, as OlsSummary.ResidualDegreesOfFreedom already is.
.converged, .fit_history["iteration"] statsmodels GlmSummary.Converged, .Iterations Identical. One addition: GlmSummary.DevianceChange reports the absolute deviance change at the last iteration, which statsmodels does not expose — read alongside Converged to see how far a non-convergent fit still is. One divergence: a non-converged fit throws an InvalidOperationException here by default (GlmOptions.ThrowOnNonConvergence), where statsmodels prints a ConvergenceWarning and returns the table anyway; setting the option to false returns it here too.
sm.add_constant statsmodels GlmOptions.WithIntercept Identical in effect: prepends a column of ones rather than requiring the caller to. GlmSummary.HasIntercept reports back whether one was fitted.
.fit(maxiter=100, tol=1e-8) statsmodels GlmOptions.MaximumIterations, .Tolerance Identical defaults: 100 iterations, and 1e-8 as the absolute bound on the change in deviance between iterations. _fit_irls passes numpy.allclose(atol=tol) and leaves rtol at its 0.0 default, so the reference's own criterion is |D_i - D_{i+1}| <= 1e-8 and nothing relative. Both settings are validated where they are set here, which the reference does not do: a budget below one or a tolerance not above zero throws rather than returning a table of 0/0.
numpy.linalg.pinv(wexog) — the pseudo-inverse the lm.WLS inside _fit_irls solves through statsmodels GeneralizedLinearModel.Fit One divergence: a rank-deficient or collinear design is refused here. The reference takes a pseudo-inverse and answers — on [[1,2],[2,4],[3,6],[4,8],[5,10],[6,12]] with an intercept and y = [0,0,1,0,1,1] it reports converged True, params [-4.24909655, 0.24280552, 0.48561103] and df_resid 4, the minimum-norm solution among the infinitely many that fit equally well. The weighted solve here refuses instead, at the first iteration whose weighted design has a smallest singular value at or below σmax·max(n, p)·ε, naming the design rather than the iteration budget: x₂ = 3·x₁ reached the 100-iteration budget before #867, with a pivot of 2e-16 rather than zero. Drop the dependent regressor.
scipy.special.gammaln(y + 1) inside Poisson.loglike_obs statsmodels GlmSummary.LogLikelihood Identical, at any count. log(y!) is read from a table below 256 and from Stirling's series above it, in constant time per observation as the reference's log-gamma is, and regression_log_factorial.json holds it to gammaln(y + 1) at a relative 1e-9 up to 2^53. A count above 1_000_000 was refused here until #665; an infinite count is refused, which the reference's gammaln answers with inf.
Family.starting_mu(y) statsmodels GeneralizedLinearModel.Fit The same start, (y + mean(y)) / 2 and (y + 0.5) / 2 for Binomial, unclamped as the reference leaves it. Both sides refuse an all-zero Poisson response, where that start is mu = 0 and the log link is -Infinity: the reference raises ValueError: The first guess on the deviance function returned a nan. and this raises ArgumentException. Clamping it off the boundary would converge instead, on coefficients the tolerance chose rather than the data — the maximum of an all-zero Poisson likelihood is at minus infinity, so there is no estimate to report.
.summary() statsmodels — (no counterpart) The formatted text block is a presentation concern; every number in it is a property of GlmSummary.

Lodestar.Survival — survival analysis

Oracled by lifelines 0.30.3 (MIT) rather than scipy. scikit-survival is refused on GPL-3.0-or-later — decision 0002.

Python Library C# Differences
KaplanMeierFitter().fit(d, event_observed=e) lifelines KaplanMeier.Estimate(d, e) Right censoring only. Confidence bounds on the log-log transform, which is lifelines' default and not the plain Greenwood interval — the latter reaches 1.0067 at S = 0.857 on 21 subjects. Where the curve reaches zero both bounds collapse to zero, as lifelines reports. Survival and hazard compared absolutely at 1e-12, bounds relatively, because the transform pushes them into the far tail. The critical value is Distributions.NormalQuantile, not a large-df Student one (decision 0003). Exact parity (9 samples).
NelsonAalenFitter().fit(d, event_observed=e) lifelines NelsonAalen.Estimate(d, e) The plain estimator, smoothing left off, as the fitter reports by default. The tie increment is not d/n: with d events at one time it is Σ 1/(n - i), so three events among 21 at risk give 0.150251 and not 0.142857. Shares its step table with Kaplan-Meier by construction. Exact parity (9 samples).
CoxPHFitter().fit(df, duration_col, event_col) lifelines CoxProportionalHazards.Fit(design, d, e, featureCount) Efron ties, the only handling CoxPHFitter offers. Right-censored, unpenalised, unstratified. The table matches at 1e-9, p-values relative, on five fixtures. Three divergences (decision 0003). 1. The corpus is lifelines fitted to its maximum at fit_options={"precision": 1e-20}: at its defaults it stops up to 8.6e-6 relative short, because its Newton-decrement test is quadratic in the gradient, so a default CoxPHFitter differs in the sixth significant figure. 2. A collinear or a separated design throws ArgumentException naming the cause, where lifelines returns a table behind a ConvergenceWarning. 3. Harrell's concordance counts identical linear predictors one half; lifelines' partial hazard rounds by row, so identical covariate rows can compare strictly there. No ties, strata, penalizer, weights_col, entry_col, prediction or baseline hazard.
cph.params_, standard_errors_, summary[["z", "p", "coef lower 95%", "coef upper 95%", "exp(coef)", ...]] lifelines CoxSummary Coefficients, StandardErrors, ZStatistics, PValues, ConfidenceLower/ConfidenceUpper and HazardRatios with HazardRatioLower/HazardRatioUpper, parallel and in the design's column order. alpha is 1 − CoxOptions.ConfidenceLevel.
cph.log_likelihood_, log_likelihood_ratio_test(), concordance_index_ lifelines CoxSummary LogLikelihood, NullLogLikelihood, LikelihoodRatioStatistic, LikelihoodRatioPValue, LikelihoodRatioDegreesOfFreedom, ConcordanceIndex. The test statistic is 2 · (LogLikelihood − NullLogLikelihood) to the last bit, as lifelines computes it.
statistics.logrank_test(dA, dB, event_observed_A=eA, event_observed_B=eB) lifelines LogRank.Test(dA, eA, dB, eB) Mantel-Haenszel form with the hypergeometric variance under ties, which carries the (n - d)/(n - 1) factor a naive implementation drops. The p-value is Distributions.ChiSquaredSf on one degree of freedom rather than a second approximation (decision 0003). Where no time compares both arms the result is a statistic of zero and a p-value of one rather than an error. Statistic absolutely at 1e-10, p-value relatively. Exact parity (5 comparisons).

Conventions

  • Comparison unit. Unless stated otherwise, string distances compare char values (UTF-16 units), which is the native .NET choice and fastest. Python libraries (rapidfuzz, jellyfish) iterate over code points: to reproduce their values exactly on supplementary text (emoji, rare ideographs), pass TextElement.CodePoint. See docs/decisions/0001-the-foundations-target-frameworks-comparison-unit-persistence-and-versioning.md.
  • ReadOnlySpan<char>. All computation signatures accept spans; string literals convert implicitly, so Levenshtein.Distance("a", "b") works with no allocation.
  • Culture. No operation is culture-sensitive by default. Overloads accepting a CultureInfo are added where case/accents matter (tokenization).
  • Stop words. StopWords.English is scikit-learn's list; the other five are Snowball's, because the nltk corpus carries no usable licence (decision 0002). This is the one place where the library knowingly does not match nltk, so the gap is measured rather than described: French 154 words vs nltk's 157 (13 / 16 words apart), German 231 vs 232 (4 / 5), Portuguese 203 vs 207 (0 / 4), Spanish 308 vs 313 (2 / 7), Italian identical. Matching is ordinal against the analyzer's output, so StripAccents = true also stops accented entries from matching — as it does in scikit-learn.
⚠️ **GitHub.com Fallback** ⚠️