-
Notifications
You must be signed in to change notification settings - Fork 588
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
Return efficient strategies for simple filter predicates, instead of rejection sampling #2701
Comments
Hi @Zac-HD ,
I suppose this comment belongs to "What about... ?" section :-), but maybe that is something worth considering from the start. |
Yeah, for those cases the strategy would be
I think in general we'll have to turn to |
Just some more thoughts:
I sketched some rough ideas in this issue on icontract-hypothesis which are also related to this issue. |
I really appreciate your input on this 😍 - thanks! Now, lots more thoughts from me... The trouble is that "perform the filtering as expected" really depends on what you expect! Because of Hypothesis' heuristics and mutation-based input generation, it's already almost impossible to describe the distribution of inputs - for example, There are two possible kinds of bugs here: either too permissive, or too restrictive. We can eliminate the former by continuing to run the predicate, and raising a "this is an internal error" if it fails. (note: adds some passthrough logic in I like the idea of using an aggressive version to provide suggestions if a filter failing too often - this can be helpful even if it's not guaranteed to be correct in all cases. The challenge is that, as for #434 (comment), it's quite difficult to unpick which sub-strategy was the problem at runtime. See #2705 for my proposal to do so as a linter instead 😁 I also think that if we're doing this we should actually use the transformations though. Often filters are simply easier to write or more natural to read than a constructive generator for the same inputs, and "please write this more complicated code we could do for you" is not a great user experience. I personally prefer to read
We may need to do this eventually, but for the first PR I'd just try to handle each in turn. We already have lazy instantiation of strategies, and this would simplify the implementation considerably. Note that if we're unable to convert some filter and apply another, we can still try to convert the latter. And a final note, if we can detect "pure predicates" for this we should apply it in |
I agree, declarative is much clearer and easier than procedural. What would be the canonical way of defining filtering on the multiple arguments of a function? I see that you can use tuples, but is there a way to have named arguments somehow? IMO, it would be important to document this upfront, even though you might optimize such strategies only in the future. Here is a trivial example. A precondition such as:
can be written as an unoptimized strategy:
and passed as Alternatively, you can use dictionaries:
and pass them as There must be many nicer ways -- whatever you pick, I'd suggest you to make sure that it can interoperate with optimizing the strategies later down the line and recommend it as such to the user in the documentation. |
Another idea re optimizations: it would be nice if the user could somehow inspect the optimization (by using a setting?) or be informed on too many rejected samples what the final optimized strategy looks like. The magic would be the default, but it needs ways for "demistification" when it fails. |
The canonical approach is For demystification - I call that "legibility", where the system supports user understanding - I think it would be appropriate to |
A first-pass prototype which mostly works: master...Zac-HD:efficient-filter Note that this is solving a complementary problem to the The next steps after getting this working are, independently, to get this working for a wider variety of scalar types, and to handle a wider variety of predicates. I'm OK with special-casing |
Hi @Zac-HD ! Could you please explain this part a little bit:
When I read the code, it seems to me that both approaches are pretty much the same (icontract-hypothesis works on AST, this approach on |
P.S.
Maybe this confused me -- if "that" refers to icontract-hypothesis, then the implementation scope is actually already wider (regular expressions are already matched as well, and the remaining contracts are given as a chain of |
I was referring to icontract-hypothesis, and ignoring regex for now - I do plan to include them soon after the basic numeric filters are done. The main difference is that your code creates strategies, while my code modifies strategies - and is therefore more flexible for users but less elegant and less reliable. |
Thanks, this clarifies it for me! |
Btw., is there a reasonable way to benchmark the overhead with this new approach as opposed to putting the bounds directly? It would make more sense for icontract-hypothesis to translate contracts into the filters with partial functions with operators (as you indicated in the docstrings) and rely on Hypothesis for the optimization rather then reimplement the very same feature. |
The overhead should be fairly small, I think, since we're talking about a one-time calculation to define the strategy rather than something which happens on every draw. I'd also recommend against switching icontract-hypothesis over at the moment; since I intend to support AST-based analysis too once I've proven the logic works. It's probably easier just to wait for that to be supported 😁 |
I've just opened #2853, to rewrite simple predicates for bounded integers. The logical next step is to extend this to integers with <2 bounds, and to floats. Fractions and decimals are also on the list, but lower priority than strings. That will probably involve re-architecting the numeric strategies to use the Further specific ideas which could be implemented independently:
|
OK, I sat down for a few hours and got AST analysis working, so that we can rewrite filters based on lambdas and other simple functions: master...Zac-HD:understand-filter-ast. PR later this week. Only supports combinations of comparisons and |
I'm assuming this is in reference to filter-rewritten strategies still having a repr which contains the lambda expression, e.g. Is this just a user experience thing? I could see the lambda expr would make more sense to the user then a possibly mystifying kwarg, or even it's problematic when debugging some complex "filtering architecture" e.g. whatever cool thing Pandera is doing. Am I missing another concern? (also gah dependabot) |
That's a now-stale reference to the unfinished branch I was working on - And yes, I 😡 the spammy bots, it's pretty rude to improve the experience of your paying customers by making life worse for maintainers 😡 |
Because regex support will be more complicated, I've moved that to a new dedicated issue and am closing this one 🙂 |
TODO:
integers()
st.one_of()
, rather than the top levelintegers()
min_size=1
from e.g.bool
orlen
orlambda x: x
floats(...)
math.isfinite
,math.isinf
, andmath.isnan
forintegers()
andfloats()
(best handled directly in the respective
.filter()
methods, rather than the fancy bounds-filtering logic)text()
/binary()
filtered withre.compile(...).find/match/fullmatch
(includes adding an internal way to exclude characters from the
from_regex()
strategy; see also Usegreenery
andregex_transformer
to mergepattern
andpatternProperties
keywords python-jsonschema/hypothesis-jsonschema#85)Rejected ideas:
len(x) > 2
- the namelen
might be rebound in ways that we can't see from the AST (e.g. monkeypatched from another module). Applies to any predicate with a function call.builds(foo, x=...).filter(lambda f: f.x > 0)
- too many ways to map init-args to attributes, and too many ways to mess with attribute access. Applies to any attribute access.tuples()
orfixed_dictionaries()
- I'd accept a PR but not a feature request for this, it's large and I just don't want to write it.operator.contains
orx.__contains__
for all core scalar strategies (this just doesn't happen often enough to justify the maintainence work to support it)st.integers().filter(lambda x: x >= 0)
is equivalent to the more efficientst.integers(min_value=0)
- see constructive vs predicative data. So why not have the.filter()
method return the efficient form when given a simple enough predicate? This is definitely possible, and using the AST module it's not even as bad as the other things we do internally ("terrible, yes, but also great..."). For completeness we should cover the equivalent cases withfunctools.partial(operator.foo, ...)
.There's a prototype here which handles scalar comparisons (
<
,<=
,>=
,>
) and regex matching, as part of @mristin's work on Parquery/icontract#177. Ideally we'd handle those cases, plus reverse operand order (1 < x
), length bounds via e.g.len(x) < 10
, non-lambda functions with equivalently simple bodies, and composition of clauses using such asx < 10 and x > 0
.For the design, I'd aim to have a function along the lines of
hypothesis.internal.reflection.bounds_from_predicate
; to start with it can return a dict of kwargs likemin_value
,exclude_max
,max_size
, etc. and then defining a.filter()
method on e.g.BoundedIntStrategy
which calls it and falls back to the current approach if the predicate is not understood.Is this a good idea? Maybe not, but we already have similar logic for e.g. Django field validators. It would also make life a lot easier for people who infer their tests from types + predicates, which includes basically all design-by-contract libraries (lately both
deal
andicontract
), and of course direct users in a hurry. Pandera's data synthesis (implementation) would also be far more efficient if refactored to take advantage. Symbolic execution tools like Crosshair wouldn't gain much though, since they can discover such constraints dynamically.Doesn't this break performance intuitions? I don't think it's worse than e.g. our existing special handling for
lists(sampled_from(...), unique=True)
which converts quadratic to linear time! It's already difficult to reason about performance in the presence of rejection-sampling plus mutation-based example generation, and while this can be a substantial runtime improvement it's only a constant-factor speedup and falls back to the current behaviour in the worst case. So long as we consistently have this work in the covered cases I think it's worth having, and it is based on a consistent static analysis.What about ... ? Great question, but let's get the basic cases covered before looking at e.g.
integers().filter(lambda x: x % 2).filter(lambda x: x > 9)
->integers(min_value=5).map(lambda x: x * 2)
. Other fancy ideas: passing through mapped and one_of strategies, handling tuples and fixed_dictionaries (for*args, **kwargs
patterns), predicates involvingor
, etc. I'm not opposed in principle to fancy stuff, since it's nicely self-contained and doesn't have user-facing API design issues, but simple stuff first!The text was updated successfully, but these errors were encountered: