Skip to content

Define a mixin for container classes with possibly masked values and use it in Time - #16844

Merged
mhvk merged 4 commits into
astropy:mainfrom
mhvk:maskable-class-use-in-time
Sep 15, 2024
Merged

mhvk merged 4 commits into
astropy:mainfrom
mhvk:maskable-class-use-in-time

Conversation

@mhvk

@mhvk mhvk commented Aug 17, 2024

Copy link
Copy Markdown
Contributor

I've started implementing proper masks for representations, roughly following what is done for Time. To help that, this PR defines a new MaskableShapedLikeNDArray mixin that provides some basics (like unmasked and filled), and also some helper routines (built on top of ShapedLikeNDArray, which all container classes already use).

In this PR, this is only used to somewhat simplify the implementation in Time, but I will use it in follow-up for coordinates. I on purpose did not yet add a changelog entry for the new method, since I'd like to keep it semi-private until I'm sure it is the right approach also for coordinates -- the fact that it is now used in Time will help ensure I keep the masking implementations consistent.

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

github-actions Bot commented Aug 17, 2024 •

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

Comment thread astropy/time/core.py Outdated
mask = False
mask, val_data = get_mask_and_data(mask, val)
mask, val_data2 = get_mask_and_data(mask, val2)
data, masks = self._get_data_and_masks(val, val2)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These methods come with MaskableShapedLikeNDArray

Comment thread astropy/time/core.py
def masked(self):
return isinstance(self._time.jd1, Masked)

@property

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are now provided by MaskableShapedLikeNDArray.

Comment thread astropy/time/core.py
return val


def get_mask_and_data(mask, val):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This basically repeated code in masked, so is now superfluous.

Comment thread astropy/utils/masked/core.py Outdated
but also used in `~astropy.utils.masked.Masked`.
"""

@classmethod

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All four methods are simply copied from Masked and MaskedNDArray.

@mhvk

mhvk commented Aug 22, 2024

Copy link
Copy Markdown
Contributor Author

@taldcroft - I realize you may be using your astropy time mostly for tables, but if you have a chance, I'd appreciate a look at this PR factoring out some of the Time mask-related methods -- with the idea to use them for coordinates (for representations, see #16845). Thanks!

@taldcroft

Copy link
Copy Markdown
Member

@mhvk - I will have a look, but on first glance this will require my thinking hat so not this evening!

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

Overall this looks good and I see now that it is basically an uncontroversial refactoring.

One thing for discussion is making the private utility class methods here into public functions. Unless I missed something, none of them actually use cls anywhere, and they are somewhat generic for masks and a masked protocol or np.ma.MaskedArray.

Comment thread astropy/utils/masked/core.py Outdated
Comment on lines +77 to +90
mask = getattr(data, "mask", None)
if mask is not None:
try:
data = data.unmasked
except AttributeError:
if not isinstance(data, np.ma.MaskedArray):
raise
if data is np.ma.masked:
if allow_ma_masked:
data = None
else:
raise ValueError("cannot handle np.ma.masked here.") from None
else:
data = data.data

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's a bit out of scope (since this PR is mostly a refactor), but I have some suggestions on the code.

  • Remove a big code indent with an early return.
  • Avoid unnecessary re-use of data. With a mindset of type-annotated code, redefining a variable seems like an idiom to avoid when possible. In this case I think the code is much more clear.
  • Improve exception messages
Suggested change
mask = getattr(data, "mask", None)
if mask is not None:
try:
data = data.unmasked
except AttributeError:
if not isinstance(data, np.ma.MaskedArray):
raise
if data is np.ma.masked:
if allow_ma_masked:
data = None
else:
raise ValueError("cannot handle np.ma.masked here.") from None
else:
data = data.data
mask = getattr(data, "mask", None)
if mask is None:
return data, mask
try:
unmasked = data.unmasked
except AttributeError:
if not isinstance(data, np.ma.MaskedArray):
raise TypeError(
"data must have an 'unmasked' attribute or be np.ma.MaskedArray"
) from None
if data is np.ma.masked:
if allow_ma_masked:
unmasked = None
else:
raise ValueError(
"cannot handle np.ma.masked here, use allow_ma_masked=True"
) from None
else:
unmasked = data.data
return unmasked, mask

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I went with your suggestion to make this function public, and decided that, given that, I should also simplify it. So, I followed your suggestion to change the indentation, but removed the option of dealing with np.ma.masked (which should have included np.ma.nomask anyway).

Comment thread astropy/utils/masked/core.py Outdated
----------
data : array-like
Possibly masked item, judged by whether it has a ``mask`` attribute.
If so, checks for being an instance of `~astropy.utils.masked.Masked`

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.

Suggested change
If so, checks for being an instance of `~astropy.utils.masked.Masked`
If so, checks for having an ``unmasked`` attribute

@mhvk mhvk Aug 24, 2024 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adjusted text, thanks!

Comment thread astropy/utils/masked/core.py Outdated
return data, mask

@classmethod
def _get_data_and_masks(cls, *args):

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 would suggest renaming this to _get_datas_and_masks. I really like the idiom of consistently using plural for list-like objects. This gives a strong type hint to the reader and makes iteration over the object obvious to code using the singular.

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.

Maybe also use arrays instead of args if this will be public. Overall in some of these functions I feel like array is a better name than data since that name narrows the type considerably. But that may be sliding into "perfect is the enemy of good" territory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry, should have answered here: I ended up thinking that the plural was not needed as much (or at least it wasn't clear it was going to be needed a lot), so just left this on the class to be used by __array_ufunc__.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

p.s. Did go with array in the general get_data_and_mask

@mhvk
mhvk force-pushed the maskable-class-use-in-time branch from 0a7f5d6 to 816d41f Compare August 24, 2024 23:46
@mhvk

mhvk commented Aug 24, 2024

Copy link
Copy Markdown
Contributor Author

Thanks, @taldcroft! I had been wondering about functions versus class methods, but your comment made clear functions is the answer. The only annoyance is that I couldn't stop myself from then using the function also in the function_helpers... (indeed, this uncovered a bug, though I'll deal with that in a separate PR...)

@mhvk
mhvk force-pushed the maskable-class-use-in-time branch from 816d41f to f74be70 Compare August 25, 2024 00:12

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

@mhvk - thanks, this is looking nice now! Just one substantive question and then documentation or nits.

copy : bool optional
Whether to ensure a copy is made. Only relevant if a single
input mask is not `None`, and ``out`` is not given.
"""

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.

Needs a Returns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread astropy/utils/masked/core.py Outdated
Parameters
----------
fill_value : object
Value to replace masked values with. Note that if this value is masked

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.

Finish the sentence and add a Returns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

How weird - that was already unfinished in Time... I guess it should have been that the mask is ignored, but to be honest, I feel that is such a weird corner case, it is better not even to mention it... But added a Returns section.

Comment thread astropy/utils/masked/core.py Resolved
np.logical_or(out, mask, out=out, where=where)
return out
@staticmethod
def _get_data_and_masks(arrays):

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.

Since it is so easy with Copilot it's worth a docstring here.

        """
        Extracts the data and masks from the given arrays.

        Parameters
        ----------
        arrays : iterable
            An iterable of arrays.

        Returns
        -------
        datas, masks
            A tuple of extracted data arrays and mask arrays, respectively.
        """

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

OK, thanks. Used slightly adjusted.



def _get_data_and_masks(*args):
def _get_data_and_mask(array):

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.

Optional, but maybe naming this _get_data_and_mask_as_array might help make the intent clear in the code that calls this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, good suggestion -- as is, it is just confusing that the name is the same with just an underscore added. I made it _get_data_and_mask_array (saving the plural for the other routine).

@mhvk
mhvk force-pushed the maskable-class-use-in-time branch from f74be70 to 9746745 Compare August 25, 2024 13:03
@mhvk
mhvk force-pushed the maskable-class-use-in-time branch 2 times, most recently from c9fc542 to 4922c27 Compare September 11, 2024 17:33
@mhvk

mhvk commented Sep 11, 2024

Copy link
Copy Markdown
Contributor Author

@taldcroft - would you have time to do a final review for this refactoring of Masked and its use in Time? I hope to now use it all in coordinates...

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

This looks good now from the code perspective.

Since it adds 3 items to the masked subpackage public API, there should be a changelog entry as such. It might also be worth mentioning that the Time base class now inherits from MaskableShapedLikeNDArray, mostly to advertise how this new formalism can be used elsewhere.

You may even want a What's New entry for this to advertise this nice addition. If you get the coordinates version through for 7.0 then definitely this will need highlighting.

…sses.

As part of this, moves _combine_masks and some other class methods from
Masked to a new MaskedMethods class so that they can be reused by
MaskableShapedLikeNDArray as well.
@mhvk
mhvk force-pushed the maskable-class-use-in-time branch from 4922c27 to f7f212e Compare September 14, 2024 20:18
@mhvk

mhvk commented Sep 14, 2024

Copy link
Copy Markdown
Contributor Author

OK, I added a changelog entry following your suggestion. I really hope to have the changes to coordinates done before 7.0 - and that would definitely warrant a what's new in which I will mention the more general scheme one can now use.

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

Looks great, thanks!

@mhvk
mhvk merged commit e5fa5cb into astropy:main Sep 15, 2024
@mhvk
mhvk deleted the maskable-class-use-in-time branch September 15, 2024 11:28
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.

2 participants