Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error - #7328

Merged
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod
Dec 5, 2024
Merged

Moved SpecialTokens assignment after the modification to avoid "Collection Modified" error#7328
tarekgh merged 7 commits into
dotnet:mainfrom
shaltielshmid:bug/bert-tok-collection-mod

Conversation

@shaltielshmid

Copy link
Copy Markdown
Contributor

We are excited to review your PR.

So we can do the best job, please check:

  • [v] There's a descriptive title that will make sense to other developers some time from now.
  • [v] There's associated issues. All PR's should have issue(s) associated - unless a trivial self-evident change such as fixing a typo. You can use the format Fixes #nnnn in your description to cause GitHub to automatically close the issue(s) when your PR is merged.
  • [v] Your change description explains what the change does, why you chose your approach, and anything else that reviewers should know.
  • You have included any necessary tests in the same PR.

Hey all! I was working with BertTokenizer, and noticed that when I specified "BasicTokenization" and "Lowercase" then I was getting the "Collection Modified" error, since the updated dictionary is iterated over and updated in the same loop.
Solution: Just assign the dictionary after.

I didn't include an issue/tests since this was just a simple typo fix. I'm happy to expand further if needed.

@codecov

codecovBot commented Dec 2, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 98.86364% with 1 line in your changes missing coverage. Please review.

Project coverage is 68.89%. Comparing base (fb7cc25) to head (25a4c6d).
Report is 1 commits behind head on main.

Files with missing linesPatch %Lines
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs94.44%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #7328 +/- ##
==========================================
+ Coverage 68.88% 68.89% +0.01% 
==========================================
Files 1473 1473 Lines 274201 274277 +76 Branches 28419 28421 +2 ==========================================
+ Hits 188881 188972 +91 + Misses 77998 77984 -14 + Partials 7322 7321 -1 
FlagCoverage Δ
Debug68.89% <98.86%> (+0.01%)⬆️
production63.30% <94.73%> (+<0.01%)⬆️
test89.43% <100.00%> (+0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing linesCoverage Δ
...icrosoft.ML.Tokenizers/Model/WordPieceTokenizer.cs75.69% <100.00%> (ø)
...icrosoft.ML.Tokenizers.Tests/BertTokenizerTests.cs100.00% <100.00%> (ø)
src/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs63.70% <94.44%> (+4.01%)⬆️

... and 7 files with indirect coverage changes

@michaelgsharp

Copy link
Copy Markdown
Contributor

@shaltielshmid thanks for submitting this!

@tarekgh any other thoughts?

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

I haven't tested this, just a theory - but a fear I have with the lowercase is that even though we identify the special tokens and keep them as a separate unit, because they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.
I think we need to add it to the vocab as well, as is done in the AddSpecialToken function. @michaelgsharp what do you think?

@tarekgh

Copy link
Copy Markdown
Member

@shaltielshmid Thank you for catching that! Could you please add a test using the same code that reproduced the issue?

@tarekghtarekgh self-assigned this Dec 4, 2024
@tarekghtarekgh added this to the ML.NET 4.0 milestone Dec 4, 2024
@tarekgh

Copy link
Copy Markdown
Member

I marked this for 4.0 as will be good to service this fix.

CC @ericstj

@tarekgh
tarekgh requested a review from CopilotDecember 4, 2024 17:24

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 1 out of 1 changed files in this pull request and generated no suggestions.

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

they are converted to lowercase they aren't going to be identified at the vocabulary lookup stage.

This is not true. When lowercase option is turned on, the whole input text will be lowered cased before processing. This will always guarantee to handle the special tokens correctly.

I think we need to add it to the vocab as well, as is done in the AddSpecialToken function.

We don't need to add the lowered case to the vocab. We don't lowercase all vocab, and we shouldn't do it. as I mentioned, with enabling lowercasing, will always lowercase the input text before processing and this produces the correct result. You can try to test it yourself. You may look at the test

Assert.Equal("[cls] hello, how are you? [sep]",normalizedText);
too.

@shaltielshmid

shaltielshmid commented Dec 4, 2024

Copy link
Copy Markdown
ContributorAuthor

@tarekgh Thank you for the response!

Looking into it further, there seems to be a bigger question here.

In the vocabulary, the special tokens are stored in uppercase (e.g., [CLS], [MASK], etc.).

When the user does not specify the SpecialTokens, then the special tokens dictionary is created in the code. When this is done, the tokens are normalized before adding them to the SpecialTokens dictionary, and they are also appended to the vocabulary in their lowercase form:

if(lowerCase)
{
// Lowercase the special tokens to have the pre-tokenization can find them as we lowercase the input text.
// we don't even need to do case-insensitive comparisons as we are lowercasing the input text.
normalizedToken=token.ToLowerInvariant();
// Add lowercased special tokens to the vocab if they are not already there.
// This will allow matching during the encoding process.
vocab[newStringSpanOrdinalKey(normalizedToken)]=id;
}
specialTokens[normalizedToken]=id;

This solves the issue of identifying the lowercased special tokens when tokenizing the text, but also brings up a few questions:

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

  • It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

This lead to an issue with the way the SpecialTokens are handled when specified explicitly:

If the SpecialTokens keys were passed in uppercase (as would be expected), then the code creates a copy of the dictionary, where each key appears both in uppercase and lowercase - which is different than what happens when the dictionary is created internally, and also causes it to crash when creating the SpecialTokensReverse dictionary (since we have multiple keys pointing to the same value).
In addition, since the values aren't added to the vocabulary, then they won't be converted to the correct token.

Example code to reproduce: The same test you pointed me to here, with the following change:

Replace the line that creates the BertTokenizer with this:

varspecialTokens=newDictionary<string,int>(){{"[PAD]",0},{"[UNK]",1},{"[CLS]",2},{"[SEP]",3},{"[MASK]",4},};// Create two separate options, since during the create the dictionary is manipulated and the Options variables are mutable. varoptions1=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};varoptions2=newBertOptions(){SpecialTokens=specialTokens.ToDictionary()};BertTokenizer[]bertTokenizers=[BertTokenizer.Create(vocabFile,options1),BertTokenizer.Create(vocabStream,options2)];

This will throw an error during the assert stage, since the first token will be converted to an [UNK] since a lowercase [cls] doesn't exist in the vocabulary.

In order for this to work, you need to take the change from my commit in this PR, and also replace the following line:

SpecialTokensReverse=specialTokensis not null?specialTokens.ToDictionary(kvp =>kvp.Value, kvp =>kvp.Key):null;

with:

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

With all this being said, the simplest solution is to make the special tokens handling be the same in both cases - create a copy of the special tokens dictionary with only the lowercase keys, and add them explicitly to the vocabulary (as is done in AddSpecialToken).

What do you think?

@tarekgh

tarekgh commented Dec 4, 2024

Copy link
Copy Markdown
Member

Thanks @shaltielshmid!

1] There is a hidden assumption here that the SpecialTokens dictionary keys will be lowercased.

That is right when the lower-case option is specified. This is important because we lower case the input text we tokenize before processing it.

2] What if the lowercased special token already existed in the vocabulary, as a regular token? Of course, this is not a very likely scenario, but worth considering.

This doesn't matter much. the current code is just doing vocab[new StringSpanOrdinalKey(normalizedToken)] = id;. So, it will just override the id value there which even correct the problem if the vocab was using wrong id for that special token and specifying lowercasing.

3] In the WordPieceTokenizer.cs file, a reversed dictionary is created (SpecialTokensReverse) - does it make sense that this dictionary value points to the lowercased token?

We can think of normalizing the tokens when creating this reverse mapping. Note, this is internal so far anyway.

It seems like the usage of this dictionary is just to check if an ID is a special token, so to solve this we can just replace this variable with a HashSet of the IDs.

Although we have this as internal property today, it is possible we'll need to expose it in the future to allow mapping the special token id to the string token. Having it as a dictionary should be correct to use for that.

I stand corrected regarding adding the lowered cased special tokens to the vocab. I forgot we already doing that when the special tokens not provided in the options. Yes, we need to have consistent behavior whether the special tokens are provided or not.
We could call AddSpecialToken on the provided special tokens

if(options.SpecialTokensis not null){if(lowerCase){Dictionary<string,int>dic=options.SpecialTokens.ToDictionary(kvp =>kvp.Key, kvp =>kvp.Value);foreach(varkvpinoptions.SpecialTokens){AddSpecialToken(vocab,dic,kvp.Key,lowerCase:true);}// I commented the following line too to avoid overwriting the special tokens in the options. we may consider doing the same in the case when the special tokens are not provided too.// options.SpecialTokens = dic; }}

One thing we can consider is not overwriting the special tokens inside the options. This can be done by storing the special tokens in a local variable and use it in the line

options.PreTokenizer??=options.ApplyBasicTokenization?PreTokenizer.CreateWordOrPunctuation(options.SplitOnSpecialTokens?options.SpecialTokens:null):PreTokenizer.CreateWhiteSpace();

This should help not needing the change

SpecialTokensReverse=options.SpecialTokensis not null?options.SpecialTokens.GroupBy(kvp =>kvp.Value).ToDictionary(g =>g.Key, g =>g.First().Key):null;

But I don't mind having this change anyway just in case anyone provides a duplicated Ids special tokens.

Let me know if there is anything unclear or if you want me to help edit your PR. Thanks for your help!

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thank you for the detailed response @tarekgh !

I updated the code as discussed, and added tests for both.

Two things I'd like to note:

1] When the code dynamically creates the special tokens dictionary, I kept a separate dictionary of the un-normalized tokens which I assigned to the BertOptions.SpecialTokens so that it would propagate onwards to the WordPieceTokenizer class.

  • I kept the tokens not normalized, so that the behavior would match when passing in the tokens explicitly and when the code creates it.
  • We still need to modify the BertOptions variable so that the SpecialTokens gets propagated to the WordPieceTokenizer, even though we would rather not modify it.

2] This is a general comment about the tokenizer with lowercase - in hugginface's tokenizers library, they extract the special tokens before the normalization, so that they only match exact-case matches. (To be precise, they store a flag for each special token specifying whether the special token is pre/post normalizing, and then they have two procedures where they extract the special tokens - before normalization and before pre-tokenization).

This will result in different behavior, where in Python we would have:

fromtransformersimportAutoTokenizertok=AutoTokenizer.from_pretrained('bert-base-uncased')
tok.tokenize("[cls] hello")
# Output: ['[', 'cl', '##s', ']', 'hello']

And in Microsoft.ML:

tokenizer=BertTokenizer.Create(vocab_file,options);tokenizer.EncodeToTokens("[cls] hello",out_));// Output: ["[CLS]", "hello"]

Not critical to change, but we should be aware that there is this discrepancy. If you're interested, I'm happy to try and create a separate PR which addresses this issue.

Comment threadsrc/Microsoft.ML.Tokenizers/Model/BertTokenizer.cs Outdated
@tarekgh

tarekgh commented Dec 5, 2024

Copy link
Copy Markdown
Member

@shaltielshmid your changes look good, I left a comment if you can address it.

@shaltielshmid

Copy link
Copy Markdown
ContributorAuthor

Thanks for your comment - great point, I updated the code accordingly.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Thanks @shaltielshmid

@tarekgh
tarekgh merged commit 01c4164 into dotnet:mainDec 5, 2024
@tarekgh

Copy link
Copy Markdown
Member

/backport to release/4.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/4.0: https://github.com/dotnet/machinelearning/actions/runs/12184398388

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@shaltielshmid@michaelgsharp@tarekgh