Files
Maven/internal/router/semantic/split.go
T
claude d63619bd0e router/semantic: frozen holdout split and grouped CV (slice 13)
Add FrozenHoldoutSplit with deterministic 15% ratio using dedicated hash
seed. Produces frozen/dev partition with SplitGroup-aware leakage
prevention — all contrastive variants of one seed stay in the same split.

Add GroupedCVFolds for k-fold grouped cross-validation on the development
pool. Each fold preserves split_group boundaries; every example appears
in exactly one eval set across all folds.

Tests verify determinism (same split → same hash), no split_group leakage
across frozen/dev, route coverage in both pools, fold completeness, and
grouped CV coverage.
2026-09-07 02:08:30 +04:00

159 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package semantic
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
)
// SplitRatio controls the frozen holdout fraction of the total corpus.
// 0.15 means 15% of families go to the frozen holdout, 85% to the
// development pool.
const DefaultFrozenRatio = 0.15
// SplitByFamily divides examples into train/eval splits such that all
// paraphrases or contrastive variants of one seed stay in the same split.
//
// The algorithm is deterministic: each SplitGroup gets a hash, and the hash
// bucket determines the split. This prevents the failure mode where
// "выключи свет" lands in train and "пожалуйста выключи свет" lands in eval.
//
// splitRatio controls the train fraction (0.01.0). 0.8 means 80% train.
func SplitByFamily(exs []RouteExample, splitRatio float64) (train, eval []RouteExample) {
if splitRatio <= 0 || splitRatio >= 1 {
splitRatio = 0.8
}
families := groupByFamily(exs)
famKeys := sortedFamilyKeys(families)
for _, k := range famKeys {
members := families[k]
h := sha256.Sum256([]byte(k))
bucket := float64(h[0]) / 256.0
if bucket < splitRatio {
train = append(train, members...)
} else {
eval = append(eval, members...)
}
}
return
}
// FrozenHoldoutSplit splits the corpus into a frozen holdout and a
// development pool. The frozen holdout must never be used for weight fitting,
// hyperparameter selection, threshold tuning, or generating near-duplicate
// training examples.
//
// The split is deterministic and identified by FrozenHoldoutHash.
func FrozenHoldoutSplit(exs []RouteExample) (frozen, development []RouteExample, holdoutHash string) {
families := groupByFamily(exs)
famKeys := sortedFamilyKeys(families)
// Use a dedicated hash seed for the frozen split so changing the
// train/eval ratio does not move the holdout.
const splitSeed = "semantic-router-frozen-v1"
for _, k := range famKeys {
members := families[k]
h := sha256.Sum256([]byte(splitSeed + ":" + k))
bucket := float64(h[0]) / 256.0
if bucket < DefaultFrozenRatio {
frozen = append(frozen, members...)
} else {
development = append(development, members...)
}
}
// Compute holdout hash from the group IDs in the frozen set.
groupIDs := make(map[string]bool)
for _, e := range frozen {
groupIDs[e.SplitGroup] = true
}
ids := make([]string, 0, len(groupIDs))
for id := range groupIDs {
ids = append(ids, id)
}
sort.Strings(ids)
h := sha256.Sum256([]byte(hex.EncodeToString([]byte(strings.Join(ids, "|")))))
holdoutHash = hex.EncodeToString(h[:16])
return
}
// GroupedCVFolds splits a development pool into k folds, keeping all
// examples with the same SplitGroup in one fold. Returns k folds; each fold
// is the eval set, the rest are the training set.
func GroupedCVFolds(exs []RouteExample, k int) []CVFold {
if k <= 1 {
k = 5
}
families := groupByFamily(exs)
famKeys := sortedFamilyKeys(families)
// Assign families to folds round-robin for balance.
foldFamilies := make([][]string, k)
for i, key := range famKeys {
foldFamilies[i%k] = append(foldFamilies[i%k], key)
}
folds := make([]CVFold, k)
for i := 0; i < k; i++ {
evalGroups := make(map[string]bool)
for _, g := range foldFamilies[i] {
evalGroups[g] = true
}
var train, eval []RouteExample
for _, e := range exs {
if evalGroups[e.SplitGroup] {
eval = append(eval, e)
} else {
train = append(train, e)
}
}
folds[i] = CVFold{Fold: i, Train: train, Eval: eval}
}
return folds
}
// CVFold — one fold of a grouped cross-validation split.
type CVFold struct {
Fold int
Train []RouteExample
Eval []RouteExample
}
// FamilyID derives a stable family ID from a base source ID and a transform
// name, ensuring contrastive variants share the base's family.
func FamilyID(baseSourceID, transform string) string {
if transform == "" {
return baseSourceID
}
h := sha256.Sum256([]byte(baseSourceID + ":" + transform))
return "family:" + hex.EncodeToString(h[:8])
}
// groupByFamily groups examples by SplitGroup.
func groupByFamily(exs []RouteExample) map[string][]RouteExample {
families := make(map[string][]RouteExample)
for _, e := range exs {
key := e.SplitGroup
if key == "" {
key = e.SourceID
}
families[key] = append(families[key], e)
}
return families
}
// sortedFamilyKeys returns the sorted keys of a families map.
func sortedFamilyKeys(families map[string][]RouteExample) []string {
keys := make([]string, 0, len(families))
for k := range families {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}