-
Notifications
You must be signed in to change notification settings - Fork 243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve precision of float multiple_of
validation
#1373
base: main
Are you sure you want to change the base?
Conversation
please review |
Codecov ReportAll modified and coverable lines are covered by tests ✅ 📢 Thoughts on this report? Let us know! |
CodSpeed Performance ReportMerging #1373 will not alter performanceComparing Summary
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your work on this!
I think we should wait for a review from @samuelcolvin or @davidhewitt on this one. They're well versed regarding the nitty gritty details of floating point ops in rust, and I think they could offer valuable feedback on this new approach.
I left one comment - I'd prefer that we leave all existing tests + add new ones as well, rather than removing some existing tests.
I'm ok with changing this, but I think before we accept this PR we should discuss a bit what we want Really, I think users expect this to match Python, so I assume that broadly we want the Python expression Due to accuracy limitations of floating point, there are probably limitations on the ranges we can accept. Do the absolute and relative error thresholds give us enough control here? Do we need to give users any config levers to pull, if we're changing this? |
Thank you for your feedback. Indeed, I agree that we should first clarify what If the result of the multiple_of validation should be the same as Python's def is_multiple_of(value, multiple_of):
return value % multiple_of == 0
multiple_of = 0.01
test_values = range(-10, 10)
print(f"Testing for multiple_of = {multiple_of}")
print("-" * 45)
print("value | is_multiple_of | value % multiple_of")
print("-" * 45)
for value in test_values:
result = is_multiple_of(value, multiple_of)
remainder = value % multiple_of
print(f"{value:5} | {str(result):14} | {remainder}") ❯ python test.py
Testing for multiple_of = 0.01
---------------------------------------------
value | is_multiple_of | value % multiple_of
---------------------------------------------
-10 | False | 2.0816681711721685e-16
-9 | False | 1.8735013540549517e-16
-8 | False | 1.6653345369377348e-16
-7 | False | 1.457167719820518e-16
-6 | False | 1.249000902703301e-16
-5 | False | 1.0408340855860843e-16
-4 | False | 8.326672684688674e-17
-3 | False | 6.245004513516506e-17
-2 | False | 4.163336342344337e-17
-1 | False | 2.0816681711721685e-17
0 | True | 0.0
1 | False | 0.00999999999999998
2 | False | 0.009999999999999959
3 | False | 0.009999999999999938
4 | False | 0.009999999999999917
5 | False | 0.009999999999999896
6 | False | 0.009999999999999875
7 | False | 0.009999999999999854
8 | False | 0.009999999999999834
9 | False | 0.009999999999999813 However, in the existing specification,
Indeed, due to the precision limitations of floating-point numbers, there are restrictions for extremely large or small values. However, we believe the threshold settings we've incorporated can cover most common cases. (If necessary, we can implement more extensive test cases.) In any case, I cannot decide on my own what result the |
@davidhewitt @sydney-runkle |
@K-dash, absolutely, will take a close look this afternoon. Thanks so much for your work here! |
Ah, after reviewing again, I do think we need @davidhewitt re:
Your tests look good to me, but let's defer to DH regarding this logical choice. Will follow up with you tomorrow 👍 |
Thank you for checking! I'll be looking forward to response. |
Sure thing. DH is out today, but should be back on Monday and able to respond 👍 |
This is high on my list of priorities and I've been doing some thinking on it, but I also have some fairly pressing stuff to complete this week. Will do my best to give a full writeup of my thoughts ASAP. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So previously, the algorithm could be described as
- calculate remainder of division (NB not great for negative numbers)
- require absolute remainder to be smaller than 1e-9
- require absolute difference of remainder and
multiple_of
to also be smaller than 1e-9
The proposal here seems to be to:
- move away from that absolute difference of 1e-9 to something which scales better with
multiple_of
. - fix the bug with negative numbers.
Both objectives make sense to me.
I think the thing I'm least clear on is the threshold being scaled back all the way to EPSILON * 100.0
. This feels like it is way stricter than the previous implementation?
Should we instead consider making a multiple_of_tolerance
parameter, which defaults to the original 1e-9
?
let relative_error = if float != 0.0 { diff / float.abs() } else { 0.0 }; | ||
|
||
// Threshold (considering both relative and absolute error) | ||
let threshold = epsilon_threshold.max(multiple_of * epsilon_threshold); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be summarised as: if multiple_of > 1
, then threshold will be increased by the same corresponding factor.
let threshold = epsilon_threshold.max(multiple_of * epsilon_threshold); | ||
|
||
// Check if the difference exceeds the threshold and the relative error is significant | ||
if diff > threshold && relative_error > epsilon_threshold { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So
diff > threshold
basically is asking for the absolute error to be greater than epsilon (with a factor of 100), possibly scaled up by multiple_of.relative_error > epsilon_threshold
is basically asking ifdiff / float
is greater than epsilon.
// Calculate the difference between the rounded value and the original value | ||
let diff = (float - rounded * multiple_of).abs(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels to me like given we evaluate threshold
in units of multiple_of
, we might want to do similarly here to end up with same scale of multiple_of
(by dividing through by rounded
)?
// Calculate the difference between the rounded value and the original value | |
let diff = (float - rounded * multiple_of).abs(); | |
// Calculate how close the rounded division is to multiple_of | |
let diff = (float / rounded - multiple_of).abs(); |
(and sorry for the very long delay, btw) |
Change Summary
Improved precision of float
multiple_of
validation and added test cases.Refine the validation logic for better handling of floating-point rounding errors
Expand test cases to cover a wider range of scenarios
Add identical tests for decimal multiple_of to ensure consistency with float
pydantic-core/tests/validators/test_decimal.py::test_decimal_multiple_of
Related issue number
fix pydantic/pydantic#9871
Checklist
pydantic-core
(except for expected changes)Selected Reviewer: @samuelcolvin