Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RecordBatch normalization (flattening) #6758

Open
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

ngli-me
Copy link
Contributor

@ngli-me ngli-me commented Nov 20, 2024

Which issue does this PR close?

Closes #6369.

Rationale for this change

Adds normalization (flattening) for RecordBatch, with normalization via Schema. Based on pandas/pola-rs.

What changes are included in this PR?

Are there any user-facing changes?

@ngli-me ngli-me changed the title Feature/record batch flatten RecordBatch normalization (flattening) Nov 20, 2024
@ngli-me ngli-me changed the title RecordBatch normalization (flattening) RecordBatch normalization (flattening) Nov 20, 2024
… iterative function for `RecordBatch`. Not sure which one is better currently.
@github-actions github-actions bot added the arrow Changes to the arrow crate label Nov 23, 2024
Copy link
Contributor Author

@ngli-me ngli-me left a comment

Choose a reason for hiding this comment

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

I had some questions regarding the implementation of this, since the one example from PyArrow doesn't seem to clarify on the edge cases here. Normalizing the Schema seems fairly straight forward to me, I'm just not sure on

  1. Whether the iterative or recursive approach is better (or something I missed)
  2. If DataType::Struct is the only DataType that requires flattening. To me, it looks like that's the only one that can contained nested Fields.

(I'm also not sure if I'm missing something with unwrapping like a List<Struct>)

Any feedback/help would be appreciated!

arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
arrow-schema/src/schema.rs Outdated Show resolved Hide resolved
@ngli-me ngli-me marked this pull request as ready for review November 23, 2024 19:03
@ngli-me ngli-me marked this pull request as draft November 23, 2024 23:30
@ngli-me ngli-me marked this pull request as ready for review November 25, 2024 04:02
@alamb
Copy link
Contributor

alamb commented Dec 18, 2024

@kszlim can you please help review this PR ? You requested the feature and we are currently quite short on review capacity in arrow-rs

Copy link
Contributor

@alamb alamb left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution @ngli-me and I apologize for the delay in reviewing.

Hopefully @kszlim can give this a look and help us review / get it moving too.

@@ -394,6 +396,56 @@ impl RecordBatch {
)
}

/// Normalize a semi-structured [`RecordBatch`] into a flat table.
///
/// If max_level is 0, normalizes all levels.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you please improve this documentation (maybe copy from the pyarrow version)?

  1. Doucment what max_level means (in addition to that 0)
  2. Document what separator does
  3. provide an example of flatteing a record batch as a doc example?

For example like https://docs.rs/arrow/latest/arrow/index.html#columnar-format

Screenshot 2024-12-18 at 8 05 08 AM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, missed doing this, will do!

@@ -413,6 +413,81 @@ impl Schema {
&self.metadata
}

/// Returns a new schema, normalized based on the max_level
/// This carries metadata from the parent schema over as well
Copy link
Contributor

Choose a reason for hiding this comment

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

Likewise, please document the parametrs to this function and add a documentation example

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, thanks!

@kszlim
Copy link
Contributor

kszlim commented Dec 19, 2024

I'll take a look, though please feel free to disregard anything I say and especially defer to the maintainers.

arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
arrow-schema/src/schema.rs Show resolved Hide resolved
DataType::Struct(ff) => {
// Need to zip these in reverse to maintain original order
for (cff, fff) in c.as_struct().columns().iter().zip(ff.into_iter()).rev() {
let new_key = format!("{}{}{}", f.name(), separator, fff.name());
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if there's a better way to structure it, but is there a way to keep the field name parts in a Vec and create the flattened fields at the end? That allows you to avoid the repeated format! in a deeply nested schema.

Might not be worth the trouble though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is a good point, this is definitely not my favorite way to do this. I'll have to do some testing and think about it some more, but it may be better to construct the queue with the components of the Field, then go through and construct all of the Fields at the very end.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a (hopefully) better approach for this that concats the Vec<&str> when the field is done being processed.

@ngli-me
Copy link
Contributor Author

ngli-me commented Dec 19, 2024

Thank you for this contribution @ngli-me and I apologize for the delay in reviewing.

Hopefully @kszlim can give this a look and help us review / get it moving too.

No problem at all, it's the holiday season! Hope everyone's taking a good break.

Appreciate the feedback though! I'll get to work on it :)

@ngli-me ngli-me marked this pull request as draft December 20, 2024 12:27
@ngli-me
Copy link
Contributor Author

ngli-me commented Dec 31, 2024

Sorry for the delays on this one, made changes based on the feedback, would appreciate another look! Hopefully the new documentation is more clear.

@ngli-me ngli-me marked this pull request as ready for review December 31, 2024 06:41
Copy link
Contributor

@Jefffrey Jefffrey left a comment

Choose a reason for hiding this comment

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

Some potential simplifications

Comment on lines +452 to +455
pub fn normalize(&self, separator: &str, mut max_level: usize) -> Result<Self, ArrowError> {
if max_level == 0 {
max_level = usize::MAX;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
pub fn normalize(&self, separator: &str, mut max_level: usize) -> Result<Self, ArrowError> {
if max_level == 0 {
max_level = usize::MAX;
}
pub fn normalize(&self, separator: &str, max_level: Option<usize>) -> Result<Self, ArrowError> {
let max_level = max_level.unwrap_or(usize::MAX);

imo this seems the more Rusty way, making use of Option instead of a sentinel value (though I'm not sure if Some(0) is a valid input?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay I've been working on this a bit, I found a few possible solutions that might fit. I think Option might not be the best choice, since personally, the case of Some(0) feels weird to me, and would mean you're doing an annoying copy for no reason (because of that, I would want to add in an if statement to catch it, but then we end up in the same place).

For RecordBatch, this seems to fit the Rusty syntax better, but unfortunately the same solution can't be echoed over to Schema without an additional dependency, not sure how I feel about that.

max_level.is_zero().then(|| max_level = usize::MAX);

Another option is to use something like NonZeroUsize, which I just learned about. My issue with this one is that we'd then be making the normalize call more annoying, since you have to instantiate it with something like

NonZeroUsize::new(1)

This makes the normalize call potentially longer and more annoying, but it means there wouldn't be another import.

Any thoughts on these/if you disagree?

Copy link
Contributor

Choose a reason for hiding this comment

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

the case of Some(0) feels weird to me, and would mean you're doing an annoying copy for no reason (because of that, I would want to add in an if statement to catch it, but then we end up in the same place).

Personally I find this okay. I'm less concerned with requiring an if check inside the code (its pretty simple anyway) compared to presenting a more Rust-like interface to users.

but unfortunately the same solution can't be echoed over to Schema without an additional dependency, not sure how I feel about that.

I don't follow this, the Schema code looks almost identical to the RecordBatch version.

I agree with NonZeroUsize potentially being a bit clunky for users to use (personally wasn't aware this was part of the stdlib either).

But yeah I'm curious to see what others might think for this too.

arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
if max_level == 0 {
max_level = usize::MAX;
}
let mut queue: VecDeque<(usize, &ArrayRef, Vec<&str>, &DataType, bool)> = VecDeque::new();
Copy link
Contributor

Choose a reason for hiding this comment

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

This queue seems to instead behave like a stack; it does push_back only when initializing the queue, but otherwise does pop_front/push_front; would it be more intuitive to just use a Vec to more accurately indicate this is a stack?

Also another note is you could remove need for storing DataType and nullability as separate tuple fields by just storing the original &FieldRef and retrieving DataType and nullability from it on demand; reduces the number of tuple fields by one which might be worth considering there's quite a few fields already.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This makes some sense to me, I think with that approach we would need to reverse the Vec after the initial instantiation, that way we can just use pop. I'll do a bit of testing with this one.

Ah, that's a good point on the &FieldRef, changed it over, thanks!

Copy link
Contributor

Choose a reason for hiding this comment

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

You should be able to rev() the iter self.columns.iter().zip(self.schema.fields()) and then collect straight into a Vec I believe

arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
arrow-array/src/record_batch.rs Outdated Show resolved Hide resolved
}

#[test]
fn normalize_nested() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps have some test cases with some more complex types thrown in as well? e.g. have a ListArray with a StructArray within

(Even if to prove that the Struct within the List shouldn't be affected)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, I'll work on these. I was a little hesitant since I'm not sure how many cases I need to cover (also since these tests are really annoying to instantiate), but it is a current blind spot.

…d if statements, simplified the VecDeque fields.
@ngli-me
Copy link
Contributor Author

ngli-me commented Jan 5, 2025

Appreciate the feedback, as always. Changed some bits of the code, added some responses (and some stuff to work on).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
arrow Changes to the arrow crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support RecordBatch.flatten
4 participants