Skip to content

Commit 35c7295

Browse files
riccardopinosioRJKeevil
authored andcommitted
add pre tokenization option to token classification pipeline
1 parent 593c620 commit 35c7295

3 files changed

Lines changed: 199 additions & 3 deletions

File tree

backends/pipeline.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ type TokenizedInput struct {
125125
AttentionMask []uint32
126126
SpecialTokensMask []uint32
127127
Offsets [][2]uint
128+
WordIDs []int
128129
MaxAttentionIndex int
129130
}
130131

hugot_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,19 @@ func tokenClassificationPipeline(t *testing.T, session *Session) {
724724
pipelineNone, err3 := NewPipeline(session, configNone)
725725
checkT(t, err3)
726726

727+
// Split-words enabled pipeline
728+
configSplit := TokenClassificationConfig{
729+
ModelPath: modelPath,
730+
Name: "testPipelineSplitWords",
731+
Options: []TokenClassificationOption{
732+
pipelines.WithSimpleAggregation(),
733+
pipelines.WithIgnoreLabels([]string{"O"}),
734+
pipelines.WithSplitWords(),
735+
},
736+
}
737+
pipelineSplit, errSplit := NewPipeline(session, configSplit)
738+
checkT(t, errSplit)
739+
727740
var expectedResults map[int]pipelines.TokenClassificationOutput
728741
err4 := json.Unmarshal(embedded.TokenExpectedByte, &expectedResults)
729742
checkT(t, err4)
@@ -769,6 +782,65 @@ func tokenClassificationPipeline(t *testing.T, session *Session) {
769782
}
770783
})
771784
}
785+
786+
// Expect same entities as the simple aggregation for the equivalent sentence for split words
787+
t.Run("Split words aggregation", func(t *testing.T) {
788+
words := [][]string{{"My", "name", "is", "Wolfgang", "and", "I", "live", "in", "Berlin", "."}}
789+
batchResult, err := pipelineSplit.RunWords(words)
790+
checkT(t, err)
791+
printTokenEntities(batchResult)
792+
expected := expectedResults[0]
793+
for i, predictedEntities := range batchResult.Entities {
794+
assert.Equal(t, len(expected.Entities[i]), len(predictedEntities))
795+
for j, entity := range predictedEntities {
796+
expectedEntity := expected.Entities[i][j]
797+
assert.Equal(t, expectedEntity.Entity, entity.Entity)
798+
assert.Equal(t, expectedEntity.Word, entity.Word)
799+
}
800+
}
801+
})
802+
803+
t.Run("Split words yields different offsets vs double-space input", func(t *testing.T) {
804+
// Non-split input with double space changes raw offsets.
805+
nonSplit := []string{"New York is great."}
806+
splitWords := [][]string{{"New", "York", "is", "great", "."}}
807+
808+
resNonSplit, errNon := pipelineSimple.RunPipeline(nonSplit)
809+
checkT(t, errNon)
810+
resSplit, errSplitRun := pipelineSplit.RunWords(splitWords)
811+
checkT(t, errSplitRun)
812+
813+
// Compare first sequence: entity words may match, but offsets should differ due to space normalization.
814+
if len(resNonSplit.Entities) > 0 && len(resSplit.Entities) > 0 && len(resNonSplit.Entities[0]) > 0 && len(resSplit.Entities[0]) > 0 {
815+
eNon := resNonSplit.Entities[0][0]
816+
eSplit := resSplit.Entities[0][0]
817+
assert.NotEqual(t, fmt.Sprintf("%d-%d", eNon.Start, eNon.End), fmt.Sprintf("%d-%d", eSplit.Start, eSplit.End))
818+
}
819+
})
820+
821+
// Pre-tokenization splitting on 'X': expect different entity results from the non-split case
822+
t.Run("Split on X detects entity", func(t *testing.T) {
823+
nonSplit := []string{"XBerlinXXisXbeautiful."}
824+
split := [][]string{{"Berlin is", "beautiful", "."}}
825+
826+
resNonSplit, errNS := pipelineSimple.RunPipeline(nonSplit)
827+
checkT(t, errNS)
828+
resSplit, errSW := pipelineSplit.RunWords(split)
829+
checkT(t, errSW)
830+
831+
gotA := resNonSplit.Entities[0]
832+
gotB := resSplit.Entities[0]
833+
// Expect split-words to detect 'Berlin' as an entity, while non-split should not because of the confusing X characters.
834+
hasBerlin := func(es []pipelines.Entity) bool {
835+
for _, e := range es {
836+
if strings.EqualFold(e.Word, "Berlin") {
837+
return true
838+
}
839+
}
840+
return false
841+
}
842+
assert.True(t, !hasBerlin(gotA) || len(gotA) < len(gotB), "expected split-words to surface 'Berlin' or increase entity count")
843+
})
772844
}
773845

774846
func tokenClassificationPipelineValidation(t *testing.T, session *Session) {

pipelines/tokenClassification.go

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type TokenClassificationPipeline struct {
2121
IDLabelMap map[int]string
2222
AggregationStrategy string
2323
IgnoreLabels []string
24+
SplitWords bool
2425
}
2526
type Entity struct {
2627
Entity string
@@ -98,6 +99,14 @@ func WithIgnoreLabels(ignoreLabels []string) backends.PipelineOption[*TokenClass
9899
}
99100
}
100101

102+
// WithSplitWords enables word-level alignment like Hugging Face's is_split_into_words.
103+
func WithSplitWords() backends.PipelineOption[*TokenClassificationPipeline] {
104+
return func(pipeline *TokenClassificationPipeline) error {
105+
pipeline.SplitWords = true
106+
return nil
107+
}
108+
}
109+
101110
// NewTokenClassificationPipeline Initializes a feature extraction pipeline.
102111
func NewTokenClassificationPipeline(config backends.PipelineConfig[*TokenClassificationPipeline], s *options.Options, model *backends.Model) (*TokenClassificationPipeline, error) {
103112
defaultPipeline, err := backends.NewBasePipeline(config, s, model)
@@ -179,6 +188,9 @@ func (p *TokenClassificationPipeline) Validate() error {
179188

180189
// Preprocess tokenizes the input strings.
181190
func (p *TokenClassificationPipeline) Preprocess(batch *backends.PipelineBatch, inputs []string) error {
191+
if p.SplitWords {
192+
return fmt.Errorf("split-words enabled: use RunWords/PreprocessWords for [][]string inputs")
193+
}
182194
start := time.Now()
183195
backends.TokenizeInputs(batch, p.Model.Tokenizer, inputs)
184196
atomic.AddUint64(&p.Model.Tokenizer.TokenizerTimings.NumCalls, 1)
@@ -187,6 +199,79 @@ func (p *TokenClassificationPipeline) Preprocess(batch *backends.PipelineBatch,
187199
return err
188200
}
189201

202+
// PreprocessWords tokenizes pre-split words and maps tokens to word IDs via offsets.
203+
func (p *TokenClassificationPipeline) PreprocessWords(batch *backends.PipelineBatch, inputs [][]string) error {
204+
start := time.Now()
205+
// Join words with single spaces to simulate pretokenized behavior
206+
joined := make([]string, len(inputs))
207+
wordBoundaries := make([][][2]uint, len(inputs))
208+
// local helper to convert non-negative int to uint safely
209+
toUintNonNeg := func(i int) uint {
210+
if i < 0 {
211+
return 0
212+
}
213+
return uint(i)
214+
}
215+
for i, words := range inputs {
216+
joined[i] = strings.Join(words, " ")
217+
// compute boundaries in joined string
218+
var boundaries [][2]uint
219+
pos := 0
220+
for wIdx, w := range words {
221+
startPos := pos
222+
endPos := pos + len(w)
223+
// clamp to non-negative and convert safely to uint
224+
// ensure non-negative before converting to uint
225+
if startPos < 0 {
226+
startPos = 0
227+
}
228+
if endPos < 0 {
229+
endPos = 0
230+
}
231+
boundaries = append(boundaries, [2]uint{toUintNonNeg(startPos), toUintNonNeg(endPos)})
232+
// add one space after every word except last
233+
if wIdx < len(words)-1 {
234+
pos = endPos + 1
235+
} else {
236+
pos = endPos
237+
}
238+
}
239+
wordBoundaries[i] = boundaries
240+
}
241+
backends.TokenizeInputs(batch, p.Model.Tokenizer, joined)
242+
atomic.AddUint64(&p.Model.Tokenizer.TokenizerTimings.NumCalls, 1)
243+
atomic.AddUint64(&p.Model.Tokenizer.TokenizerTimings.TotalNS, safeconv.DurationToU64(time.Since(start)))
244+
245+
// Map token offsets to word indices
246+
for i := range batch.Input {
247+
input := batch.Input[i]
248+
boundaries := wordBoundaries[i]
249+
wordIDs := make([]int, len(input.Offsets))
250+
for t := range input.Offsets {
251+
if input.SpecialTokensMask[t] > 0 {
252+
wordIDs[t] = -1
253+
continue
254+
}
255+
tokStart := input.Offsets[t][0]
256+
tokEnd := input.Offsets[t][1]
257+
id := -1
258+
for w := range boundaries {
259+
b := boundaries[w]
260+
// assign if token lies within the word span
261+
if tokStart >= b[0] && tokEnd <= b[1] {
262+
id = w
263+
break
264+
}
265+
}
266+
wordIDs[t] = id
267+
}
268+
batch.Input[i].WordIDs = wordIDs
269+
// also set raw to joined string for offsets consistency
270+
batch.Input[i].Raw = joined[i]
271+
}
272+
return backends.CreateInputTensors(batch, p.Model, p.Runtime)
273+
}
274+
190275
// Forward performs the forward inference of the pipeline.
191276
func (p *TokenClassificationPipeline) Forward(batch *backends.PipelineBatch) error {
192277
start := time.Now()
@@ -257,6 +342,7 @@ func (p *TokenClassificationPipeline) GatherPreEntities(input backends.Tokenized
257342
endInd := input.Offsets[j][1]
258343
wordRef := sentence[startInd:endInd]
259344
isSubword := len(word) != len(wordRef)
345+
// In split-words mode, grouping will use offsets between tokens rather than IsSubword.
260346
// TODO: check for unknown token here, it's in the config and can be loaded and compared with the token
261347
// in that case set the subword as in the python code
262348
preEntities = append(preEntities, Entity{
@@ -355,15 +441,29 @@ func (p *TokenClassificationPipeline) aggregateWords(entities []Entity) ([]Entit
355441
for _, entity := range entities {
356442
if len(wordGroup) == 0 {
357443
wordGroup = []Entity{entity}
358-
} else if entity.IsSubword {
359-
wordGroup = append(wordGroup, entity)
360-
} else {
444+
continue
445+
}
446+
// Default behavior: group by IsSubword boundaries
447+
groupBreak := !entity.IsSubword
448+
if p.SplitWords {
449+
// In split-words mode, we group by contiguous tokens of the same word boundary since we pretokenized.
450+
// Since preEntities don’t carry word IDs directly, simulate grouping by contiguous offsets:
451+
// break group if there is a gap between previous End and current Start (space) or heuristic non-subword.
452+
// TODO: eventually we should export word IDs from the tokenizer to avoid this heuristic but the rust tokenizer bindings don't expose this yet
453+
// and we also use other tokenizers in go backend.
454+
prev := wordGroup[len(wordGroup)-1]
455+
// if there is a gap in offsets consider it a new word
456+
groupBreak = entity.Start > prev.End
457+
}
458+
if groupBreak {
361459
aggregated, err := p.aggregateWord(wordGroup)
362460
if err != nil {
363461
return nil, err
364462
}
365463
wordEntities = append(wordEntities, aggregated)
366464
wordGroup = []Entity{entity}
465+
} else {
466+
wordGroup = append(wordGroup, entity)
367467
}
368468
}
369469
if len(wordGroup) > 0 {
@@ -516,3 +616,26 @@ func (p *TokenClassificationPipeline) RunPipeline(inputs []string) (*TokenClassi
516616
runErrors = append(runErrors, postErr)
517617
return result, errors.Join(runErrors...)
518618
}
619+
620+
// RunWords runs the pipeline for pre-split word inputs.
621+
// Each input is a slice of words representing a pretokenized sentence.
622+
// This is particularly useful when the user wants to control tokenization because of special tokens,
623+
// hashtags, or other domain-specific tokenization needs.
624+
func (p *TokenClassificationPipeline) RunWords(inputs [][]string) (*TokenClassificationOutput, error) {
625+
var runErrors []error
626+
batch := backends.NewBatch(len(inputs))
627+
defer func(*backends.PipelineBatch) {
628+
runErrors = append(runErrors, batch.Destroy())
629+
}(batch)
630+
runErrors = append(runErrors, p.PreprocessWords(batch, inputs))
631+
if e := errors.Join(runErrors...); e != nil {
632+
return nil, e
633+
}
634+
runErrors = append(runErrors, p.Forward(batch))
635+
if e := errors.Join(runErrors...); e != nil {
636+
return nil, e
637+
}
638+
result, postErr := p.Postprocess(batch)
639+
runErrors = append(runErrors, postErr)
640+
return result, errors.Join(runErrors...)
641+
}

0 commit comments

Comments
 (0)