Skip to content

ENH: add support for nan-like null strings in string replace#26355

Merged
seberg merged 4 commits intonumpy:mainfrom
ngoldbaum:fix-replace-nulls
Apr 30, 2024
Merged

ENH: add support for nan-like null strings in string replace#26355
seberg merged 4 commits intonumpy:mainfrom
ngoldbaum:fix-replace-nulls

Conversation

@ngoldbaum
Copy link
Member

This fixes an issue similar to the one fixed by #26353.

In particular, right now np.strings.replace calls the count ufunc to get the number of replacements. This is necessary for fixed-width strings, but it turns out to make it impossible to support null strings in replace.

I went ahead and instead found the replacement counts inline in the ufunc loop. This lets me add support for nan-like null strings, which it turns out pandas needs.

I marked this one as a backport and issued it separately from the other PR because the ufuncs fixed by the other PR aren't going to be in numpy 2.0.

@ngoldbaum ngoldbaum added the 09 - Backport-Candidate PRs tagged should be backported label Apr 27, 2024
@ngoldbaum ngoldbaum requested a review from lysnikolaou April 27, 2024 02:19
Copy link
Contributor

@mhvk mhvk left a comment

Choose a reason for hiding this comment

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

Looks good modulo some nitpicks...

goto next_step;
}
else {
npy_gil_error(PyExc_ValueError,
Copy link
Contributor

Choose a reason for hiding this comment

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

Indentation off

}
else {
npy_gil_error(PyExc_ValueError,
"Only nan-like null values are not supported "
Copy link
Contributor

Choose a reason for hiding this comment

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

Delete "Only"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, fixed the double-negative and tweaked the wording. Hopefully the version I just pushed reads better.

Buffer<ENCODING::UTF8> buf2((char *)i2s.buf, i2s.size);
Buffer<ENCODING::UTF8> buf3((char *)i3s.buf, i3s.size);
Buffer<ENCODING::UTF8> outbuf(new_buf, max_size);
{
Copy link
Contributor

Choose a reason for hiding this comment

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

Why the new indentation? It already is in the loop.

(And it makes reviewing harder...)

Copy link
Member Author

@ngoldbaum ngoldbaum Apr 29, 2024

Choose a reason for hiding this comment

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

It's because of the new use of goto next_step, I need to define a new lexical scope or define a bunch of variables at the top of the for loop that are only used at the bottom of it, otherwise the compiler complains about jumping over variable declarations.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd probably have gone for top of the for-loop myself, but no big deal...

Copy link
Member

Choose a reason for hiding this comment

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

While I don't hate the while (N--) loop in general, I do think a goto for loop control flow isn't nice and I much prefer a long for instead.
But this file has this pattern in a few places right now so it doesn't matter since the other places use this pattern also.

npy_int64 end = NPY_MAX_INT64;

PyMem_RawFree(new_buf);
npy_int64 found_count = string_count<ENCODING::UTF8>(buf1, buf2, start, end);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd just hard code buf1, buf2, 0, NPY_MAX_INT64 - that seems clearer than defining variables that are only used here.

"as search strings for replace");
npy_gil_error(PyExc_ValueError,
"Only NaN-like null strings can be used "
"as as search strings for replace");
Copy link
Contributor

Choose a reason for hiding this comment

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

Now clearer, but this has a double "as as"

Copy link
Member

@seberg seberg left a comment

Choose a reason for hiding this comment

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

Looks good to me too, and if we are in a rush, we could put it in.

However, what we are missing are tests for the error paths, I think even the now fixed nan-like null path is untested?

Unless I mind-slipped, I also think the size calculation is odd and should try to use count now?

}
else {
// replace i2 with i3
max_size = i1s.size * (i3s.size/i2s.size + 1);
Copy link
Member

Choose a reason for hiding this comment

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

That didn't change, but now that you have count you should use it, I think.

Also, am I confused by the division. It seems correct, but a bit overly complicated, since you can use i1s.size + difference giving:

change = i2.size >= i3.size ? 0 : i3.size - i2.size;
max_size = i1s.size + count * change;

I.e. we replace at most count items (it might be less, if we can find overlaps with string_count. If overlaps are impossible in string_count then I guess the count might be exact).

Copy link
Member Author

@ngoldbaum ngoldbaum Apr 30, 2024

Choose a reason for hiding this comment

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

Thanks. I agree this logic here is poorly motivated and using the count directly makes more sense.

}
npy_int64 found_count = string_count<ENCODING::UTF8>(
buf1, buf2, 0, NPY_MAX_INT64);
if (found_count == -2) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (found_count == -2) {
if (found_count < 0) {

Yes, it returns -2 due to fastsearch, but let's clarify that it can't actually return -1

Buffer<ENCODING::UTF8> buf2((char *)i2s.buf, i2s.size);
Buffer<ENCODING::UTF8> buf3((char *)i3s.buf, i3s.size);
Buffer<ENCODING::UTF8> outbuf(new_buf, max_size);
{
Copy link
Member

Choose a reason for hiding this comment

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

While I don't hate the while (N--) loop in general, I do think a goto for loop control flow isn't nice and I much prefer a long for instead.
But this file has this pattern in a few places right now so it doesn't matter since the other places use this pattern also.

else {
npy_gil_error(PyExc_ValueError,
"Only NaN-like null strings can be used "
"as search strings for replace");
Copy link
Member

Choose a reason for hiding this comment

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

(just a curious note for now)

I think default strings don't actually hit this, right? The only subtlety (which I don't care about), is that the we don't mutate the default string stored on the dtype probably, but rather insert the same string every time.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah good point; this error message isn't quite right, using a string as a missing string is also supported. Will update the error to match this.

Not sure what you're getting at about mutating strings, but that's why they're static strings that store the string data in a const buffer. Anyone mutating it is going out of their way to do so.

Copy link
Member

@seberg seberg Apr 30, 2024

Choose a reason for hiding this comment

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

I was thinking of:

dt1 = StringDType(na_value="spam")

replace(arr(..., dtype=dt1), "spam", "parrot")

doesn't give a StringDtype(na_value="parrot"), I think so "bloats" memory.

I don't mind that enough to worry (at least fo rnow, I think this is a niche feature)

EDIT: Sorry, first edit didn't use the same replacemnt as was the na_value... Also, to be clear, I am not sure that should happen!

"strip",
"lstrip",
"rstrip",
"replace"
Copy link
Member Author

Choose a reason for hiding this comment

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

@seberg this change makes sure the error paths are tested.

else {
npy_gil_error(PyExc_ValueError,
"Only NaN-like null strings can be used "
"as search strings for replace");
Copy link
Member Author

Choose a reason for hiding this comment

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

Ah good point; this error message isn't quite right, using a string as a missing string is also supported. Will update the error to match this.

Not sure what you're getting at about mutating strings, but that's why they're static strings that store the string data in a const buffer. Anyone mutating it is going out of their way to do so.

}
else {
// replace i2 with i3
max_size = i1s.size * (i3s.size/i2s.size + 1);
Copy link
Member Author

@ngoldbaum ngoldbaum Apr 30, 2024

Choose a reason for hiding this comment

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

Thanks. I agree this logic here is poorly motivated and using the count directly makes more sense.

@seberg
Copy link
Member

seberg commented Apr 30, 2024

Thanks for following up on the count also!

@seberg seberg merged commit 4e6d2bf into numpy:main Apr 30, 2024
charris pushed a commit to charris/numpy that referenced this pull request May 2, 2024
…6355)

This fixes an issue similar to the one fixed by numpy#26353.

In particular, right now np.strings.replace calls the count ufunc to get the number of replacements. This is necessary for fixed-width strings, but it turns out to make it impossible to support null strings in replace.

I went ahead and instead found the replacement counts inline in the ufunc loop. This lets me add support for nan-like null strings, which it turns out pandas needs.
@charris charris removed the 09 - Backport-Candidate PRs tagged should be backported label May 2, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants