d63619bd0e
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.
159 lines
4.5 KiB
Go
159 lines
4.5 KiB
Go
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.0–1.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
|
||
}
|