Skip to content

NEP 41: First steps towards improved Datatype Support - #15506

Merged
seberg merged 33 commits into
numpy:masterfrom
seberg:NEP41
Mar 11, 2020
Merged

seberg merged 33 commits into
numpy:masterfrom
seberg:NEP41

Conversation

@seberg

@seberg seberg commented Feb 4, 2020 •

Copy link
Copy Markdown
Member

This NEP is supposed to:

  1. Motivate the whole DType change and lay out a rough plan.
  2. Make some high level decisions (I would like to most "easy" decision out of the way), if something is controversial it will be moved out of here. If I am missing some easy, related decision, it should be added.
  3. Decide that we start implementing NEP 42 proposal (the DTypeMeta class and DType (sub)classes), without the need for finishing NEP 42 fully.

@mattip

mattip commented Feb 10, 2020

Copy link
Copy Markdown
Member

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?

@rgommers

Copy link
Copy Markdown
Member

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

@rgommers rgommers Feb 10, 2020 •

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.

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).

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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:

  1. We want "full featured dtypes"
  2. We currently cannot have them
  3. 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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 rgommers 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.

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.

@seberg

seberg commented Feb 10, 2020

Copy link
Copy Markdown
Member Author

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:

  1. Can agree on some of the central points by accepting this NEP. Which does not actually define any new public API, it is only supposed to give a hint at it, to say that it will be opaque and thus extensible.

  2. Get a tiny bit of momentum code wise. I think that would be helpful, and starting with the one decision that type(np.dtype("f8") is a subclass of np.dtype is the central piece that is necessary for everything else.

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 type(np.dtype("f8")), so most users will not even notice; so if we change it completely there will not a lot of fallout.

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.

@rgommers

Copy link
Copy Markdown
Member

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.

@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:)

@seberg

seberg commented Feb 10, 2020

Copy link
Copy Markdown
Member Author

@rgommers, probably. But quite frankly, the only "example", I can think of is:

array_of_categorical = np.array(["spam", "eggs"], dtype=Categorical)
array_with_meters = np.array([1., 3.] dtype=Unit("m"))

And it feels like you probably expect more?

@stefanv stefanv changed the title NEP: Create NEP41, proposing a rough plan for new DTypes NEP 41: Proposing a rough plan for new DTypes Feb 11, 2020
@charris charris closed this Feb 13, 2020
@charris charris reopened this Feb 13, 2020
@seberg seberg changed the title NEP 41: Proposing a rough plan for new DTypes NEP 41: First steps towards improved Datatype Support Feb 14, 2020
@mhvk

mhvk commented Feb 16, 2020

Copy link
Copy Markdown
Contributor

@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:

array_of_categorical = np.array(["spam", "eggs"], dtype=Categorical)
array_with_meters = np.array([1., 3.] dtype=Unit("m"))

But also let those actually be what you think they should be! For the Categorical, I guess what is missing is what the actual possible categories are. It might also not be clear that what is fed into np.array would normally be a string (rather than, say, an Enum).

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 f8[m/s]. (Still, this is something that makes me wonder about whether units really belong on the dtype - should there really be a dtype instance for every combination of unit and numerical type? While floats are most common, some complex numbers can sensibly have numbers, and so can some integers and even fractions (e.g., for spin or other type of Quanta). In the end, currently the dtype just tells how to interpret bytes as values, while units are about what those values mean. But then again, there is of course the casting/conversion part that is really similar between units and dtypes.

Anyway, all this mostly to second that examples do matter a lot!

@rgommers

Copy link
Copy Markdown
Member

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 (pint provides the latter, with support for array protocols), you have fewer places to hook in. So thinking about what goes missing then may show the need for some design changes, or lead to a conclusion that dtypes aren't the best fit after all.

@charris charris closed this Feb 16, 2020
@charris charris reopened this Feb 16, 2020
@seberg

seberg commented Feb 16, 2020

Copy link
Copy Markdown
Member Author

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 dtype encoding that "each value is a meter" is a much easier concept. I think Quantities do this in a similar way, that works. But it does seem better for users if they have one layer less to think of.

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 __array_ufunc__ when it comes to working with other containers. And have no adoption threshold: as soon as they exist, you can put them into your pandas dataframe.

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 __array_function__ :). Keeping up with that and all the other array-likes out there is a moving target: lets make the target smaller!

@mhvk

mhvk commented Feb 16, 2020

Copy link
Copy Markdown
Contributor

@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 dtype that encode how uncertainty is stored? The dtype could "know" how uncertainty propagates... Or how about a mask? My sense is that a general mechanism that allows propagation is super-useful, but it is not quite obvious what should be part of the dtype, or how a dtype allows combinations.

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 int24 type (I want an int2... we deal with 2-bit radio VLBI data all the time...).

@charris

charris commented Feb 17, 2020

Copy link
Copy Markdown
Member

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.

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.

@seberg

seberg commented Feb 17, 2020

Copy link
Copy Markdown
Member Author

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:

  1. It can be nice to have "induced" methods for DTypes, we currently have no such notion. I had the idea of arr.elem.method() before, but without a (possibly dynamic) subclass/mixin indicated by the dtype this is not possible (a bit like what pandas does, the dtype provides some functions, but often is only used to find the correct array subclass). The current np.ndarray is arguably one with "induced" methods for Numerical purposes.
  2. Units on the array use np.ndarray as their computational kernel. That limits how they can cast datatypes, etc. with other NumPy datatypes (for example datetime64 already has a unit). But, it has the advantage that a unitless Unit can obviously work with every UFunc. If Unit was part of the dtype, this is less obvious. There may be ways to design a protocol to make it work, but for starters the user may have to do something (and know about it), like units.ensure_unitless_loop_exists(ufunc).

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?

@rgommers

Copy link
Copy Markdown
Member

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.

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!)

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.

@rgommers

Copy link
Copy Markdown
Member

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:

  • design can be changed again since it's mostly internal, use cases will remain
  • use cases can also be used to derive documentation from
  • finally, they give the future developers context, so when they make more changes they won't go against original intent

@rgommers

Copy link
Copy Markdown
Member

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.

@mhvk

mhvk commented Feb 18, 2020

Copy link
Copy Markdown
Contributor

@seberg - my sense would be to move the examples into two categories, one that include things like int24 and (I think) Categorical that would become directly possible with a new dtype system, and others (units only?) that may become much easier, but for which we simply do not yet know. I agree with @rgommers about not deleting pieces! Indeed, if at all possible, try to work some of the discussions into the NEP, as it will help avoid future confusion.

""""""""""""""""""""""

Mainly used where memory is a consideration, lower-precision numeric types
such as ``bfloat16`` are common in other computational frameworks.

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.

A reference link to bfloat16 would be handy here.

Comment on lines +580 to +582
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.

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@eric-wieser eric-wieser Mar 9, 2020 •

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.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sorry, almost, since there is byte swapping, and unfortunately metadata.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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:

  1. 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.
  2. This means that:
    • Scalars might need to be MetaClass instances, or ->f slots need to be tacked on to every dtype instance (->f slots 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.dtype is only necessary for extension types that wish to add methods or store units, etc.. np.isdtype or similar exists, but you might as well work with dtype.type probably.
    • 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 store datetime.datetime directly 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 do datetime.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 the datetime.datetime DType were supposed to be parametric (where do you read up on the arguments/kwargs?).

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...

@eric-wieser eric-wieser Apr 12, 2020 •

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 categorical or object where the element is an arbitrary python object are more weird if the scalar type is central. But: We may actually say that object is the only exception and a categorical result should be like an enum.Enum instance. A similar thing could be for an array of np.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 a DType class 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.dtype subclass as well.
    • DType-centric: Create a DType class and we have to help them to create a scalar as well.
  • 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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:

  1. 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 dtype instance 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.)
  2. Parametric dtypes are also normal python instances. There is some behaviour such as np.promote_types() and the NEP 42 DType.__discover_descriptor__(python_object) which has nothing to do with array values, and so it is clearly the DType class (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?).

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.

I hope to find time to read this in the next month or so...

seberg and others added 2 commits March 9, 2020 17:05
Ross does not want authorship, so he has to suffer through this ;).
@seberg
seberg merged commit ef09573 into numpy:master Mar 11, 2020
@seberg

seberg commented Mar 11, 2020

Copy link
Copy Markdown
Member Author

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.

@numpy numpy deleted a comment from eric-wieser Mar 24, 2020
@mhvk

mhvk commented Mar 25, 2020

Copy link
Copy Markdown
Contributor

(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 >i4 to <i4 is no different from doing it from >f4 to <f4. In the context of the discussion about where the leaves of the hierarchy are, it might help to think of, e.g., <i4 as actually having two attributes, one of the data type - i4 - and one of the ordering - <4 - each of which could be instances of a respective class. If so, the dtype proper would just be a collection of pieces that describe the data, and would be a class whose primary purpose is to organize order, first feeding bytes to the byte-ordering instance, then on to the data-type instance, and then to a possible unit instance.

Note I don't mean that this would necessarily be exposed to the user! But in this logic, li4 = np.dtype('<i4') would only be an instance of np.dtype itself; it would be its data_type attribute (or whatever) that is an instance of a DTInteger class and a byte_order attribute that would be an instance of ByteOrder.

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.

@seberg

seberg commented Mar 25, 2020 •

Copy link
Copy Markdown
Member Author

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 C++ or Python this would be more clear, the DType would simply have attributes with their own methods (type), such as an actual ByteOrder or Unit class. In C, I guess this just means having the attribute since you do not have methods in C, you will just have to use functions, such as a byteswap() function we can provide.

That is, of course you can (and should) use "wrapping" logic:

class Unit(np.dtype):
     # Typical np.dtype slots, such as itemsize
     wrapped_dtype_instance = None
     unit = None

(EDIT: note in the above Unit has the job of filling in itemsize from correctly from wrapped_dtype_instance, we cannot "inherit" it)

Now, actual composition, such as you have a dtype, and then you "add" byteorder to it by inheriting from it or so. I do not see how that is going to work well. Of course in C++ or so it may work, so if you define your DType in C++ I guess you could compose it using inheritance. I am not really convinced that we should go there. We can provide things like a fast DType agnostic byteswap function of course.
Actual compisition would have to figure out things like a <U4 to >U6 cast needs to have the byte-order part do byteswapping, and the unicode part elongate the string, while an encoding part may have to change the encoding... I suppose I am just not convinced it is worth giving me headaches ;). We could do things like include unit as a C-level slot, i.e. a protocol to exchange/access DType units, even if they are implemented by completely different people. I do not think any of the design stops us from this? The composition, however, may actually be tricky to tag on later. Also it may require allowing you to subclass at least DTypeMeta, but I do not have any squirms with that as such.

@mhvk

mhvk commented Mar 26, 2020

Copy link
Copy Markdown
Contributor

@seberg - your class Unit(np.dtype) is a nice way to think of it. Might there be a place for a similar class dtype(byte_order)? Though that does feel strange - subclasses are not generally meant to add a lot of new functionality, and certainly the byte_order one can hardly be something attached to an array.

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...

@seberg

seberg commented Mar 26, 2020

Copy link
Copy Markdown
Member Author

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.
Right now, I do not think NumPy really has many things that make it easier. Byteorder, sure, but it is trivial enough that there is not much to compose.

The main advantage of having composition, would be if we would allow to "extend" existing dtypes. E.g. take an int8 and say: This is also an int8 but with a weird storage pattern. Of course you can already do that by just adding a new datatype.
Composition or not, the main question is about dispatching IMO. If you have a loop for int8 should you allow downstream to say: Any ufunc written for int8 will work for my int8_with_weird_storage (since it can cast). That is something I currently only have half plans for support. I.e. as long as we do not allow subclassing, a multiple-dispatching system cannot pick up the int8 loop for your int8_with_weird_storage dtype. You could argue that is an advantage of type numbers, but I am not sure I would even agree. It is an argument for allowing (very careful!) subclasses.

As for composition, I suppose I just get back to the part where:

  1. Fine we can think about it for the Python API
  2. You are free to use a language composition support when writing your own custom dtype (e.g. in C++).

Although, yes, we could do that, we could have a Numeric type, which adds an attribute for issigned or a ByteOrderderd which adds a newbyteorder method. Aside form the limitation of static C language, I do not see much of an issue (although not sure I see much of a gain, at least immediately either, but maybe that is just because I am not used to working like that much).

@seberg

seberg commented Mar 28, 2020 •

Copy link
Copy Markdown
Member Author

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: np.dtype(np.intp) is not np.dtype(np.longlong) (even if the current type number is the same). That also means I have to be prepared for it in UFunc dispatching. That is not a big issue as such, though. And the subclassing should be trivial and OK, since both are functionally equivalent.\

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 scalar class <-> DType class mapping.

@eric-wieser

Copy link
Copy Markdown
Member

. I need subclassing in my model already (internally to NumPy), to represent the current type aliases: np.dtype(np.intp) is not np.dtype(np.longlong)

No, but I think np.dtype(np.intp) is np.dtype(np.long)?

@seberg

This comment has been minimized.

@seberg

seberg commented Mar 28, 2020

Copy link
Copy Markdown
Member Author

Sorry, you are right of course... We do add loops for both integers after all, so this is probably OK, at least for now.

@seberg

seberg commented Mar 31, 2020

Copy link
Copy Markdown
Member Author

@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.

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.