Repository navigation
NEP 41: First steps towards improved Datatype Support - #15506
Conversation
|
Perhaps the "Motivate the whole DType change" belongs in NEP 40 and the "Decide that we start implementing NEP 42 proposal" belongs in NEP 42? |
Agree with the second point, disagree with the first. Principle for both: keep things self-contained as much as possible. This is the top-level document for the new dtypes, so the motivation belongs here. |
| Some of these are partially solved; for example unit capability is provided | ||
| in ``astropy.units``, ``unyt``, or ``pint``. However, these have to subclass | ||
| or wrap ``ndarray`` whereas special datatypes would provide a much more natural | ||
| representation, and would immediately allow usage within tools such as |
There was a problem hiding this comment.
This would be a great place to add some example code to show the more natural representation (perhaps side-by-side with the current pandas/unyt/pint way).
There was a problem hiding this comment.
Actually I think you may want two separate code examples: one aimed at users of a new dtype, and one aimed at authors of libraries that would want to create new dtypes.
There was a problem hiding this comment.
Well, I left those out intentionally. There are too many moving parts and things to bike shed, IMO.
What I want to convince you of here is:
- We want "full featured dtypes"
- We currently cannot have them
- These are necessary decisions we can get out of the way before we do the detailed API design.
It may be that the text further down is too detailed in some cases, and thus hides that intention? Or do I need an example to show that Dask should simply work with a new dtype, but working with a new array-like such as astropy.units is probably less straight forward?
There was a problem hiding this comment.
Well, I tried to clarify the part of why I think subclasses are not a real solution a bit, maybe that helps. I am thinking of rather than adding a lot of new things, I may delete a bit of the discussions below (the API breakage is cleaned up a bit).
rgommers
left a comment
There was a problem hiding this comment.
Thanks Sebastian! This looks like a great start. I've only read it very quickly so far; my main comment is that it would be good to spend the time to start with the user-facing parts and extend the motivation a little. The phase I-III plan is a good high-level start, but then it dives quickly into internal design.
Also useful would be to cross-link the 4 related NEPs in PR descriptions and add the overview of what each one is for that's in the NEP 40 PR description to all of them.
|
Well, I will rephrase the "decide to start NEP 42", I currently do not wish to remove it. I will be frank, I do not see implementing this as a big bang can work. There will be way too much hesitation. The complete set of technical details are simply too many to actually settle on anything quickly, and for them not to be a somewhat in flux. Then, it may make sense to have a working example for the new public API of e.g. units. But that will be practically impossible without almost everything finished, e.g. including ufuncs and reorganizing casting, both non-trivial endeavors with moving parts. That means, I want to be able to build up at least a tiny bit of feeling of momentum. And to get there hope that we:
Now I may be bad at pushing it, but IMO unless we do 1. soon, we will just get stuck in the technical mud (again) with no one willing to make a clear decision on anything. I am willing to debate 2., but I honestly do not see a huge downside: Very few users will write Of course I can move any implementation to the next NEP and reduce that NEP as much as possible, or propose accepting e.g. only a few points of NEP 42 preliminary and then continue adding/fixing/accepting new points as we go. But I need to know what an acceptable strategy is here, deciding here to do a tiny step in the right direction code wise, seemed like a good "ice breaker" so to say. |
@seberg I think you are missing my point a little. I am not suggesting a working example. It can be code as it should look and feel without any implementation backing it up. Thing of it as the thing you'd put in a tutorial, and doctesting it is not needed. Finally, you're not making a hard promise that it will turn out exactly like that - an example that's 80-90% right is better than no example at all. Without doing that, everyone will simply imagine where you're headed, or stop reading half-way through cause it's too dense. Most of those imaginations will be wrong - we're not mind readers:) |
|
@rgommers, probably. But quite frankly, the only "example", I can think of is: And it feels like you probably expect more? |
…d clean up API breakage section
|
@seberg - am sort-of keeping half an eye on this as I am quite interested in how units could be stored in the dtype (I'm definitely of two minds - I'm still not sure unclear it would in fact be better on the dtype), and reading the above, I agree with @rgommers that a few user-facing examples would be very useful - as he notes, if you don't have it, everyone will fill in the blanks, rather than converge on a single plan. So, do put examples like the ones you quoted above: But also let those actually be what you think they should be! For the For the example with units, right now the dtype does not specify what type of float these are meant to be. Perhaps as a placeholder, you could simply use the datetime notation, i.e., something like Anyway, all this mostly to second that examples do matter a lot! |
|
For units there's a ton of existing examples in the documentation of packages, e.g.:
So just seeing what is typically done there (defining, checking, converting, simplifying, printing units, and more), and rewriting a couple of the examples to use dtypes would be instructive. Looking at all that, I actually suspect that you can only put part of that behavior in a dtype. When you have a plain numpy array instead of a subclass or duck array ( |
|
Hmm, right I will add some short usage examples, of course. I do not want to sound overly annoyed/defensive here, but I think I have to talk with Ralf in person about the real doubts. (I am honestly surprised that I may still have to convince anyone seriously about the general idea!) @mhvk I guess you would like to see some reasoning why array-likes are not a good generic solution? I will give a stab below: There is a reason why pandas encodes their array-likes by basically calling them dtypes. (In pandas the dtype is associated to a specific array like in practice). I think the The main question is why create another "protocol" for this (it basically is a very fat protocol, but it is also one we already have, albeit in pretty broken way)? The reason is that dtypes are much more clearly defined and generalize extremely well! Any NumPy dtype can be put into any container that knows about NumPy dtypes: Dask, pandas, xarray, sparse arrays, …. Pandas is not even an quite an array-like! DTypes compose even better than The main point (that I hoped to make a bit clear in the last change, but is maybe short), is that this all can work so well because dtypes encoding all information required to interpret a single elemet means that container concerns are completely separated from element concerns. Please tell me if I am wrong, but I always had the feeling that Quantities have to chase other developments (e.g. keep up with new functions) and have some quirks. On the other hand, a dtype does not have to concern itself at all with most functions removing 300 possible quirks with all the functions that are wrapped by |
|
@seberg - thanks! I think your answer just proves the point - it is useful to have this information in the NEP! I also think both the categorical and the units examples are good, as they are quite different: the categorical example shows that with the NEP ideas it will be easier to give new meaning to more or less arbitrary bytes, while the units example shows that one can add a new layer of meaning. I do not think things are trivial, though. Clearly, somewhere it has to be defined, e.g., that trig ufuncs take arguments in radians, how multiplication and division affect units, etc. Should this all be part of the dtype via the unit class? Or should ufuncs know about what units they take? And angles are particularly tricky since they are really dimensionless and in many situations one would like to treat them as such. Indeed, astropy deals with this via equivalencies, and also can handle logarithmic units (decibel, etc.), which behave differently for addition. Of course, much of this may well scale to dtypes! Also, as I noted, I do wonder about how instances would actually work - will there be versions of Meter for each float, integer and complex dtype? Or would one have some combo or stacked dtype, of a unit and a bytes-interpreter? Another question is where one stops. E.g., I have been playing with classes that also store uncertainty on a number, and found that things were substantially easier if this was in between units and dtype - perhaps not illogically, since physically the uncertainty and the value cannot have incompatible units. Would one want to have Anyway, this is not to say we need to solve all this before proceeding: I very much agree with you that the new class organization makes sense regardless, even if all we end up getting to is categoricals or the ability to define an |
The "what are the bytes" bit seems pretty straight forward, but I've been a bit skeptical about implementing units with dtypes, so an example would be good to have. @njsmith Liked the idea of using dtypes to handle units, so I'd like to know what he had in mind for the implementation. |
|
See, so maybe the solution is actually to delete Quantities here. I have thought about this (and generally the array type a bit, just to proof it: https://gist.github.com/seberg/02dc72cc126c365694f7e470504f0c03). But did not quite come to a conclusion yet for units, the end just gets more fuzzy... I still think they should be on the DType, I see two counter points:
In any case: It should not matter for this NEP!, if units or even half of the above dtypes I list are things that might be better as subclasses, I can just delete it from the motivation for now, and try to put it into another NEP (of course that might create even more NEPs :)). It also means that NEP 42, should maybe not use datetime as an example, since that is unit+nullable_int... We still need all of the things I want to sign of by accepting this NEP, so do you agree that deleting it (or making it into a "maybe" paragraph), would be a way to get this somewhere that we can agree to accepting this? |
Please add rather than delete. It's clear that there's unknowns, if it makes you feel better to state that explicitly - please do.
I think you're still missing the point of asking for examples / use cases / requirements. It's not that I have specific doubts about the implementation. It's just that I know from experience that creating a design without concrete use cases in mind inevitably leads to a sub-optimal (best case) or useless/actively-harmful (worst-case) design. As @mhvk said, it is simply useful - both for us and for yourself. If you look at software design methodologies you will see that whether it's waterfall, agile, model-driven engineering, or whatever - they all start from use cases and/or requirements. |
|
Describing use cases or external/functional requirements typically takes between 1/2 and 1/6-th of the amount of content of describing the design. It's also the content that's most useful in the future:
|
|
It may be worth asking for some help here. E.g. one of the Pandas devs (@TomAugspurger or @jorisvandenbossche any interest, or can you suggest someone?) may be willing to write up one or two examples of where this helps Pandas; @mhvk or @ngoldbaum or @hgrecco may be willing to adapt a units example of astropy.units/unyt/pint where they can see this having value. |
|
@seberg - my sense would be to move the examples into two categories, one that include things like |
| """""""""""""""""""""" | ||
|
|
||
| Mainly used where memory is a consideration, lower-precision numeric types | ||
| such as ``bfloat16`` are common in other computational frameworks. |
There was a problem hiding this comment.
A reference link to bfloat16 would be handy here.
…using his suggestions)
Co-Authored-By: Eric Wieser <[email protected]>
| This will also add the ability to use ``isinstance(dtype, np.dtype[float64])`` | ||
| thus removing the need to use ``dtype.kind``, ``dtype.char``, or ``dtype.type`` | ||
| to do this check. |
There was a problem hiding this comment.
In my opinion no one should have been doing the check this way anyway - the failsafe way to spell that has always been simply dt == float64, or np.dtype(dt) == float64 if you don't know for sure whether you have a dtype or not.
There was a problem hiding this comment.
True, we do use the kind check for np.issubdtype checks in some places I think... But that is, to some degree, for later.
| deprecated in the long run. | ||
|
|
||
| The most prominent visible side effect of this will be that | ||
| ``type(np.dtype(np.float64))`` will not be ``np.dtype`` anymore. |
There was a problem hiding this comment.
Regarding the expression type(np.dtype(np.float64)) is type(np.dtype(np.float32)):
- Is the intent for it to be true or false?
- Is this NEP specifying that it must be true or false?
- Do we want to deliberate leave it unspecified for all time, to enable us to adjust our heirarchy later?
There was a problem hiding this comment.
My intent is that is False, I am happy to define that right now. If it was not False, type(np.dtype(np.float64)) would be parametric and I think it is desirable to simplify life by having non-parametric DTypes were possible. They seems easier to me to reason about, and to define ufunc dispatching for.
For the current buildin dtypes, it also seems only right, since they already have ArrFuncs and typenumbers that are distinct.
Of course in general it should be possible to do the above, although I expect some methods may be less efficient for such datatypes, since they may need to test. It is also something to keep in mind, whether during ufunc dispatching datatype instance specialization should be allowed (i.e. allow such a float32+float64 addition loop). But, possibly this can also be added later. The other thing is that it probably can always be possible to define a float32+float64 abstract DType.
There was a problem hiding this comment.
It sounds then like you're proposing singleton instances for each of the builtin numeric dtypes? Without arguing about whether that makes sense or not yet, I think if that's really your intent, you should get the word singleton in a description somewhere further up.
There was a problem hiding this comment.
Right, almost singleton... We do have byte swapping (and unfortunately metadata). Eric, if you are unsure that is the correct path, should we chat about that briefly?
There was a problem hiding this comment.
Sorry, almost, since there is byte swapping, and unfortunately metadata.
There was a problem hiding this comment.
OK, so to continue on that. If we say dtypes are simply a "generic" object describing the data, without additional bells and whistles, then I think the scalars have to take the brunt of it:
- We will have to add at least some slots to the scalar:
- Possibly just be the class method
__new_dtype__(*args, **kwargs)and a method__dtype__(). - Casting needs a home somewhere, although it could be implemented more like ufuncs as multimethods if scalars were not MetaClasses.
- Possibly just be the class method
- This means that:
- Scalars might need to be MetaClass instances, or
->fslots need to be tacked on to every dtype instance (->fslots are much like a "class" IMO, but it may actually be neater/easier to avoid reference cycles between scalar types and DTypes). - Existing python scalars cannot add any additional methods. So we will need fake scalars, similar to
np.object_, I think. - DType classes become an implementation detail. Subclassing
np.dtypeis only necessary for extension types that wish to add methods or store units, etc..np.isdtypeor similar exists, but you might as well work withdtype.typeprobably. - The scalar class hierarchy would be important if we want to use a hierarchy for dispatching! (which I currently see it being used for). Even if not, it would be the scalars providing the hierarchy in some form.
- The DType classes would probably be considered to have less features, which may be good. The downside is if we have "fake" scalars like
np.object_. If we have DType classes next to scalar classes,np.dtype[scalar_type]can always give you what you want (I admit doing the mapping is annoying). If someone wants to storedatetime.datetimedirectly we need the same mapping somewhere, but there seems no "obvious" way to expose it to the user, which means there is no obvious way to dodatetime.datetime.get_dtype()or similar. OTOH, I suppose you can still do a function approach.np.dtype(datetime.datetime)can still work, although it seems a bit tedious if thedatetime.datetimeDType were supposed to be parametric (where do you read up on the arguments/kwargs?).
- Scalars might need to be MetaClass instances, or
So... Maybe we can avoid metaclasses (as in special storage) by pushing things more into the dtype instances (more like it is now). A tricky part seems to me that we have to "identify" scalars that have DTypes, duck typing might work but we may need to use an ABC or even a MetaClass, which I am not sure I like to force up on scalars.
The biggest issue I still have is that it seems to me pushing things into the scalar seems tricky if the scalar is supposed to know nothing about NumPy. I.e. it would almost need to be a language feature in Python?
One of the larger annoyances I have with my metaclasses, is that the relation Scalar Type<->DType type may be a bit annoying. In some cases the scalar need not know anything about the DType, but in practice at least some of our scalars must. And I doubt that circular references even matter enough, the GC tracks tens of thousands of objects on a fresh IPython start...
Anyway, babbling by now :). I am not getting closer to being absolutely sure that one option is better than the other. And I am not sure that this actually has too much to do with your thoughts Eric.
In the end maybe my mistake is to think that normal Python scalars can/should have dtypes, and we should bake in that any scalar that has a "dtype" (can be stored directly, including discovery) to be derived from an np.scalar...
There was a problem hiding this comment.
Hopefully I'll be able to come back to this conversation in the next week, just dropping in to assure you your thoughts are not going directly into the void.
There was a problem hiding this comment.
I find it hard to pin down the actual up and downsides. And of course your thoughts are probably in different direction anyway. Since I disliking the idea of a "hierarchy" outside of the class hierarchy. Of course there are arguments for using a "safe-casting" hierarchy, but I also see difficulties with that (although it sometimes generalizes a bit neater).
Up sides to a scalar-centric view:
- Probably less MetaClassing
- Less danger of circular referencing (although I doubt it matters)
- No twin-implementation DType <-> Scalar type
- No twin, possibly differing, hierarchies.
- No twin implementation means no/less danger of random people implementing a DType for a random scalar.
Down sides:
- Restricting inheritance/usage may be less desirable for scalars than for DType classes.
- The scalar type must implement a certain interface (i.e. inheriting from
np.generic, maybe even be a MetaClass instance). Or at least implement certain dunders. - Thus: Difficult or impossible to implement DType for an arbitrary scalar type.
- arrays rely on dispatching (unlike python scalars). The hierarchy is thus more important and allows for more confusion than typical Python scalars. Whatever hierarchy we use for ufunc dispatching may need to be cleaner than a typical Python scalar hierarchy.
User Impact:
- DTypes such as
categoricalorobjectwhere the element is an arbitrary python object are more weird if the scalar type is central. But: We may actually say thatobjectis the only exception and acategoricalresult should be like anenum.Enuminstance. A similar thing could be for an array ofnp.ndarray. - API Can either look more like:
scalar_type.get_dtype(*args, **kwargs)np.dtype[scalar_type](*args, **kwargs)- If we allow dtypes without a clear scalar type, both have to use:
something(*args, **kwargs), in the scalar-centric case, I am not sure I have a good story, in the second you still need aDTypeclass though. (The dtype type can be used also for the scalar-centric story, but I feel it has not quite fit in anywhere)
Implementation Impact:
- If the user has no scalar:
- Scalar-centric: Create a new scalar with a NumPy provided API, or create a scalar and add a certain protocol on. They may need to create a
np.dtypesubclass as well. - DType-centric: Create a DType class and we have to help them to create a scalar as well.
- Scalar-centric: Create a new scalar with a NumPy provided API, or create a scalar and add a certain protocol on. They may need to create a
- If the user already has a scalar:
- Scalar-centric: The user has to extend the scalar and create a dtype subclass if necessary.
- DType-centric: A DType is created, the scalar can remain unmodified (at least in principle)
But I guess, you may be coming from a bit different direction. And that is that the methods are actually more naturally defined on what we call dtype now, since it knows the exact data representation "type" of each element. And from that direction, I don't have much to add, except maybe
class Int8ScalarWithStorageDetails():
byteorder = "=" # part of dtype
byte_data
also would store the byteorder on the instance.
There was a problem hiding this comment.
After a long time, in an attempt at resolving this discussion, or make my point of why this decision. I started to think about here (yes, its too long).
I will maintain what I think are my old points:
-
We need some representation of a "String" (i.e.
np.dtype("S")). There should be a clear place where value space and behaviour is defined (but not necessarily implemented).type(np.dtype("S5"))can live up to that task- We have a clear relation that the value space and behaviour of one scalar-type is fully described by one DType
- In the model where the
dtypeinstance is considered the implementer of behaviour, we need an
abstract base class for each dtype:np.dtype("S")is almost that.
We also need either that ABC or the scalar type to take the role of defining behaviour.
(The scalar type is great, but I prefer it knows as little as possible about arrays and dtypes.)
-
Parametric
dtypesare also normal python instances. There is some behaviour such asnp.promote_types()and the NEP 42DType.__discover_descriptor__(python_object)which has nothing to do with array values, and so it is clearly theDTypeclass (meta-dtype) which should provide this behaviour.
At this point I said that I want something which "defines" (but not necessary implements) the value space and behaviour of a dtype. And I claim, that the DType class (meta-dtype) is the natural choice, because we need it anyway and because otherwise the Python model would be to have an "abstract base dtype" (equivalent to our "flexible" np.dtype("S") which technically shouldn't be attached to an array) instead.
So my perception is: We need a DType class and that DType class will effectively define/represent value space and behaviour. Even in your model, where array element behaviour should be implemented on the dtype we have to deal with this DType class (it might be possible to make it more generic), but it must exist.
However: It does not need to implement the behaviour of the array elements. We could look up behaviour just as well on the dtype directly. Our current C code looks like that in places, since it uses:
PyArray_Descr *descr = PyArray_DESCR(arr);
descr->f->setitem(...)
Note that there is a big but here: descr->f is always shared for all dtypes with the same type number. There is never any inheritance, so that the above code could just as well be:
PyArray_Descr *descr = PyArray_DESCR(arr);
descr->tp_type->setitem(...)
in all cases. And NEP 41 to, to some degree, requires this (see below to resolve this).
In NEP 41, we consider the "instance" of a single array value to be `dtype+element`:
If we look inside an Python float, it has the structure:
float instance:
type = float # The instance stores its type
value = 4.
The dtype itself is a Python type (spelling type(np.dtype("float64")) as Float64):
Float64 instance:
type = Float64
byte_order = "<"
While the individual array value can be thought of:
Float64 value:
dtype = np.dtype("<float64") # Stored on the array
value = 4.
NEP 41, basically extends the value "instance" rather than defining the type:
Float64 value:
type = Float64 # stored in `arr.dtype`
byte_order = "<" # stored in `arr.dtype`
value = 4.
The issue that I think we never talked about is the why you would want to store/implement behaviour on the dtype rather than its type. And the main reason I can think of is to "specialize" functionality for speed.
My answer to the need of specialization is to think of methods similar to unbound methods in Python, i.e. this is what ufuncs do (or will do):
Details
Kernels (DType Methods) resolve the lack of specificity:
A Python method is itself a class with a special __get__ method. When the . operator fetches a method from a class, it binds it by calling __get__. We can write our own "method" class:
from functools import partial
def func(*args, **kwargs):
print(args, kwargs)
class Method:
_func = func # function written by user
def __get__(self, instance, owner=None):
return partial(self._func, instance)
# Note that `__get__` is called and Method works:
class MyObject:
meth = Method
bound_method = MyObject().meth # calls __get__
bound_method() # prints self
We can (and in casting to some extend do) translate this to
saying that a "kernel" (DType method), also can have
a __get__ method:
class Kernel:
_func1 = function_for_little_endian
_func2 = function_for_big_endian
def __get__(self, dtype):
if dtype.byteorder == "<":
return _func1
else:
return _func2
Making it possible to write functions specific to a a certain instance
(just as it would be possible in pure Python even without inheritance).
Although, the organization of these specific function occurs
one level higher.
This __get__ method doesn't need to be exposed right away, but it is a clear
extension if "kernels" define at least almost all functionality of array values.
(Which is the clear goal with respect to ufuncs and casting.)
Once we expose the __get__ function, the DType implementer could even create their own system of inheritance by looking up behaviour on the dtype instance itself (I guess similar to monkey patching a Python instance?).
There was a problem hiding this comment.
I hope to find time to read this in the next month or so...
Co-Authored-By: Eric Wieser <[email protected]>
Co-Authored-By: Eric Wieser <[email protected]>
Ross does not want authorship, so he has to suffer through this ;).
|
Thanks everyone for your feedback and help. I am sure there will be a few fixups necessary (espeically links to the other NEPs), but we have to start somewhere and I would like to send this to the mailing list. |
|
(Been following the discussion about where the (meta)classes and instances are from the sidelines - but moving this to the main thread since it is only somewhat sideways related - though really the rest should perhaps have been in the main thread as well, to avoid loosing it!) As units came up again: I do wonder more and more whether we should not separate more clearly the different parts of "the dtype" in the three different tasks that it could encode, since those have a clear hierarchy: lowest is byteorder, needed to make the bytes interpretable, next is the real data type, giving (usually numerical) meaning to the bytes, and last is units, giving physical meaning (for numbers at least). This makes me wonder whether it is useful to think of each of these as their own classes and instances: after all, swapping the byte order from Note I don't mean that this would necessarily be exposed to the user! But in this logic, I guess bottom line is that it may be good to think about the attributes of a dtype as instances of their own classes. It might even be needed/useful, for some weird architecture that, say, stores strings in bunches with a given ordering (revenge of BCDIC?). With a clear division of tasks, using dtypes for different unit systems may also be easier. |
|
Hmm, well there are two things, here. First is defining a specific storage layer (and methods, maybe even C-level) for certain things. If this was That is, of course you can (and should) use "wrapping" logic: (EDIT: note in the above Now, actual composition, such as you have a dtype, and then you "add" |
|
@seberg - your p.s. I realize part of the discussion is about C implementation, but it may be good to think it through as far as possible as if it were a python problem, and only then really move to how it is implemented in C. But I admit writing this while never really have written the C classes, and therefore just hoping to stay away from it! p.s.2 It might also be much more productive to just start looking at the actual implementation you have been working on... |
|
I think I like to see it as an orthogonal problem. It is not really (fully) maybe though. We need a protocol, with the most basic features that a datatype must expose. As soon as we have that we could try to make it easier for users to write new datatypes. The main advantage of having composition, would be if we would allow to "extend" existing dtypes. E.g. take an As for composition, I suppose I just get back to the part where:
Although, yes, we could do that, we could have a |
|
On the subclassing front, I actually realized that I managed to miss a hole. I need subclassing in my model already (internally to NumPy), to represent the current type aliases: EDIT: OTOH, maybe it can just be a detail of the instance... Since the character code currently already is (although that would mean there is not a direct |
No, but I think |
This comment has been minimized.
This comment has been minimized.
|
Sorry, you are right of course... We do add loops for both integers after all, so this is probably OK, at least for now. |
|
@eric-wieser gentle ping. I know I have been slow on this as well, but it would be nice if we can continue that instance vs. flat hierarchy discussion. If there is some ground work I can do to help on that front, happy to do that. |
This NEP is supposed to: