Skip to content

feat: add .json.gz support and fix pandas 2.0 compatibility - #991

Closed
lyraluoyu wants to merge 1 commit into
neurostuff:mainfrom
lyraluoyu:fix-issue-robustness-plots
Closed

feat: add .json.gz support and fix pandas 2.0 compatibility#991
lyraluoyu wants to merge 1 commit into
neurostuff:mainfrom
lyraluoyu:fix-issue-robustness-plots

Conversation

@lyraluoyu

@lyraluoyu lyraluoyu commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Title: feat: support .json.gz format and fix Pandas 2.x compatibility in Dataset getter

Description:
This PR addresses two key areas of the Dataset module:

Gzip Support: Updated Dataset.load() and Dataset.save() to support .json.gz files. It now automatically detects the .gz extension and uses gzip for I/O operations, significantly reducing storage footprints for large meta-analytic datasets.

Pandas 2.x Compatibility: Fixed a regression where _generic_column_getter would crash with AttributeError: 'DataFrame' object has no attribute 'tolist'.

Root Cause: In newer Pandas versions (2.x), certain internal indexing or column access patterns within NiMARE's utilities can return a DataFrame instead of a Series (e.g., when duplicate column names are present or during specific slice operations).

Fix: Implemented a defensive check using isinstance(subset, pd.DataFrame). If a DataFrame is returned, we now explicitly select the first column via .iloc[:, 0] before calling .tolist().

Testing:
Passed all 22 tests in nimare/tests/test_dataset.py.

Verified that .json.gz loading/saving works as expected.

Confirmed backward compatibility with standard .json files and older Pandas environments.

Summary by Sourcery

Add gzip-compressed JSON support to Dataset loading and improve robustness and compatibility of DataFrame column handling across the codebase.

New Features:

  • Allow Dataset to load datasets from .json.gz files in addition to plain .json.

Bug Fixes:

  • Prevent _generic_column_getter from failing when DataFrame slices return DataFrames instead of Series, ensuring compatibility with newer pandas versions.
  • Fix image DataFrame validation utilities to work when columns may return DataFrames rather than Series.

Tests:

  • Add a dataset loading test that verifies initialization from a gzipped JSON file.

@welcome

welcome Bot commented Mar 28, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! We have detected this is the first time you have contributed to NiMARE. Please check out our contributing guidelines.
We invite you to list yourself as a NiMARE contributor, so if your name is not already mentioned, please modify the .zenodo.json file with your data right above Angie's entry. Example:

{
  "name": "Contributor, New",
  "affiliation": "Department of Psychology, Some University",
  "orcid": "<your id>"
},
{
  "name": "Laird, Angela R.",
  "affiliation": "Florida International University",
  "orcid": "0000-0003-3379-8744"
},

Of course, if you want to opt out this time there is no problem at all with adding your name later. You will be always welcome to add it in the future whenever you feel it should be listed.

@sourcery-ai

sourcery-ai Bot commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds gzip-compressed JSON support to Dataset loading, hardens generic column access against Pandas 2.x behavior changes, and adjusts image dataframe utilities for compatibility with Series/DataFrame return types while adding a regression test for compressed dataset loading.

Sequence diagram for Dataset initialization with .json.gz support

sequenceDiagram
    actor User
    participant Dataset
    participant gzip
    participant json

    User->>Dataset: __init__(source_json_gz)
    Dataset->>Dataset: isinstance_source_str
    alt source_endswith_gz
        Dataset->>gzip: open(source_json_gz, rt)
        gzip-->>Dataset: f_obj
    else source_not_gz
        Dataset->>Dataset: open(source, r)
        Dataset-->>Dataset: f_obj
    end
    Dataset->>json: load(f_obj)
    json-->>Dataset: data_dict
    Dataset->>Dataset: initialize_internal_state_with_data
Loading

Updated class diagram for Dataset and utils compatibility changes

classDiagram
    class Dataset {
        +Dataset(source, target, mask)
        +get_labels(ids)
        _generic_column_getter(attr, ids, column, ignore_columns)
        _id_cols
    }

    class Utils {
        +_validate_images_df(image_df)
    }
Loading

Flow diagram for updated _generic_column_getter logic

flowchart TD
    A[Start_generic_column_getter] --> B[Compute_all_cols_excluding_id_cols]
    B --> C{Is_column_provided}

    C -- Yes --> D[Find_matches_equal_or_prefix]
    D --> E{Matches_found}
    E -- No --> F[Raise_ValueError_with_available_types]
    E -- Yes --> G[Set_target_col_first_match]
    G --> H[subset = df_target_col]
    H --> I{ids_is_not_None}
    I -- Yes --> J[subset = subset_loc_df_id_in_ids]
    I -- No --> K[Keep_subset]
    J --> L{subset_is_DataFrame}
    K --> L
    L -- Yes --> M[result = subset_iloc_first_column_tolist]
    L -- No --> N[result = subset_tolist]
    M --> R[Check_return_first_and_result_list]
    N --> R

    C -- No --> O[available_types = all_cols_excluding_ignore_columns]
    O --> P[Init_empty_result_dict]
    P --> Q[For_each_v_in_available_types]
    Q --> S[subset = df_v]
    S --> T{ids_is_not_None}
    T -- Yes --> U[subset = subset_loc_df_id_in_ids]
    T -- No --> V[Keep_subset]
    U --> W{subset_is_DataFrame}
    V --> W
    W -- Yes --> X[result_v_first_column_tolist]
    W -- No --> Y[result_v_subset_tolist]
    X --> Z[Next_v_or_continue]
    Y --> Z
    Z --> AA[result = keys_with_any_non_None_values]
    AA --> R

    R --> AB{return_first_and_result_non_empty_list}
    AB -->|Yes| AC[Return_first_element]
    AB -->|No| AD[Return_result]
    AC --> AE[End]
    AD --> AE[End]
Loading

File-Level Changes

Change Details Files
Support loading datasets from gzip-compressed .json.gz files while preserving existing .json behavior.
  • Update Dataset.init to detect .gz suffix on string sources and load data via gzip.open in text mode when present
  • Retain existing json.load-based path for uncompressed .json or other string sources
nimare/dataset.py
Make _generic_column_getter robust to Pandas 2.x DataFrame/Series differences and slightly expand column selection semantics.
  • Compute non-ID columns once (all_cols) and reuse for validation and enumeration
  • Allow column lookups that match either exact column name or names starting with the requested prefix and report all_cols in error messages
  • For single-column retrieval, apply optional id filtering, then if the subset is a DataFrame select the first column via iloc[:, 0] before calling tolist, otherwise call tolist directly
  • For multi-column retrieval, iterate available non-ignored columns, apply optional id filtering, coerce any DataFrame subset to its first column, and build a dict of lists filtered to keys with at least one non-None value
  • Only apply the return_first optimization when the computed result is a non-empty list, then fall back to returning the full result object
nimare/dataset.py
Add regression test for compressed dataset loading via .json.gz.
  • Create a new test that gzips the existing neurosynth_dset.json into a .json.gz file and constructs a Dataset from it
  • Assert that the resulting Dataset instance is valid and has non-empty ids
nimare/tests/test_dataset.py
Adjust image dataframe validation utilities for Pandas 2.x compatibility and column-shape assumptions.
  • Use image_df[col].values.tolist() instead of .tolist() to ensure a plain Python list of file paths when checking for absolute paths
  • Replace relative-path derivation logic with direct first-column selection via iloc[:, 0] when creating "__relative" columns for absolute-path columns
nimare/utils.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In _validate_images_df, replacing the apply(lambda x: x.split(shared_path)[1] ...) logic with image_df[abs_col].iloc[:, 0] both assumes a DataFrame (will error for a Series) and drops the relative-path transformation entirely, which likely breaks the function’s core purpose; consider restoring the path-splitting while still handling the Pandas 2.x DataFrame-return case defensively.
  • The new column resolution in _generic_column_getter that treats column as a prefix (c == column or c.startswith(column)) can introduce ambiguous matches when multiple columns share a prefix; consider either requiring exact matches or raising an error when more than one column matches instead of silently taking matches[0].
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_validate_images_df`, replacing the `apply(lambda x: x.split(shared_path)[1] ...)` logic with `image_df[abs_col].iloc[:, 0]` both assumes a DataFrame (will error for a Series) and drops the relative-path transformation entirely, which likely breaks the function’s core purpose; consider restoring the path-splitting while still handling the Pandas 2.x DataFrame-return case defensively.
- The new column resolution in `_generic_column_getter` that treats `column` as a prefix (`c == column or c.startswith(column)`) can introduce ambiguous matches when multiple columns share a prefix; consider either requiring exact matches or raising an error when more than one column matches instead of silently taking `matches[0]`.

## Individual Comments

### Comment 1
<location path="nimare/utils.py" line_range="590" />
<code_context>
-            image_df_out[abs_col + "__relative"] = image_df[abs_col].apply(
-                lambda x: x.split(shared_path)[1] if isinstance(x, str) else x
-            )
+            image_df_out[abs_col + "__relative"] = image_df[abs_col].iloc[:, 0]

         image_df = image_df_out
</code_context>
<issue_to_address>
**issue (bug_risk):** New relative-path logic both breaks on Series and drops the shared_path trimming behavior.

`image_df[abs_col]` returns a Series, so `.iloc[:, 0]` will raise `IndexError: Too many indexers`. Even if changed to `.iloc[0]`, this only copies the absolute path instead of computing a path relative to `shared_path`, so `__relative` is no longer a relative path.

To fix this, preserve the relative-path computation while handling both Series and DataFrame cases, e.g. by:
- Keeping the previous `split(shared_path)[1]` behavior (or a more robust `os.path.relpath`), and
- Explicitly branching for Series vs DataFrame rather than just copying the original value.

As written, this introduces both a runtime error (for the usual Series case) and a behavior regression.
</issue_to_address>

### Comment 2
<location path="nimare/dataset.py" line_range="523-528" />
<code_context>
+        all_cols = [c for c in df.columns if c not in self._id_cols]

         if column is not None:
+            matches = [c for c in all_cols if c == column or c.startswith(column)]
+            if not matches:
+                raise ValueError(
+                    f"{column} not found in {attr}.\nAvailable types: {', '.join(all_cols)}"
+                )
+            target_col = matches[0]
+            
+            subset = df[target_col]
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Prefix-based column matching can be ambiguous and silently pick the wrong column.

Because multiple columns can share a prefix (e.g., `"z"`, `"z_desc-consistency"`, `"z_desc-contrast"`), this logic may silently select the first match in `all_cols`, making behavior order-dependent and surprising. Instead of using `matches[0]`, consider either raising an error when `len(matches) > 1` and asking callers to disambiguate, or requiring an exact match in that case.

Suggested implementation:

```python
        all_cols = [c for c in df.columns if c not in self._id_cols]

        if column is not None:
            matches = [c for c in all_cols if c == column or c.startswith(column)]
            if not matches:
                raise ValueError(
                    f"{column} not found in {attr}.\nAvailable types: {', '.join(all_cols)}"
                )

            # Prefer exact match if available; otherwise allow a single unambiguous prefix match.
            if column in matches:
                target_col = column
            elif len(matches) == 1:
                target_col = matches[0]
            else:
                # Multiple prefix matches and no exact match: require the caller to disambiguate.
                raise ValueError(
                    "Ambiguous column specification "
                    f"{column!r} for {attr}. "
                    f"Matches: {', '.join(matches)}. "
                    "Please provide a more specific or exact column name."
                )

            subset = df[target_col]

```

```python
            if ids is not None:
                subset = subset.loc[df["id"].isin(ids)]

            if isinstance(subset, pd.DataFrame):
                result = subset.iloc[:, 0].tolist()
            else:
                result = subset.tolist()
        else:

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread nimare/utils.py
image_df_out[abs_col + "__relative"] = image_df[abs_col].apply(
lambda x: x.split(shared_path)[1] if isinstance(x, str) else x
)
image_df_out[abs_col + "__relative"] = image_df[abs_col].iloc[:, 0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): New relative-path logic both breaks on Series and drops the shared_path trimming behavior.

image_df[abs_col] returns a Series, so .iloc[:, 0] will raise IndexError: Too many indexers. Even if changed to .iloc[0], this only copies the absolute path instead of computing a path relative to shared_path, so __relative is no longer a relative path.

To fix this, preserve the relative-path computation while handling both Series and DataFrame cases, e.g. by:

  • Keeping the previous split(shared_path)[1] behavior (or a more robust os.path.relpath), and
  • Explicitly branching for Series vs DataFrame rather than just copying the original value.

As written, this introduces both a runtime error (for the usual Series case) and a behavior regression.

Comment thread nimare/dataset.py
Comment on lines +523 to +528
matches = [c for c in all_cols if c == column or c.startswith(column)]
if not matches:
raise ValueError(
f"{column} not found in {attr}.\nAvailable types: {', '.join(all_cols)}"
)
target_col = matches[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Prefix-based column matching can be ambiguous and silently pick the wrong column.

Because multiple columns can share a prefix (e.g., "z", "z_desc-consistency", "z_desc-contrast"), this logic may silently select the first match in all_cols, making behavior order-dependent and surprising. Instead of using matches[0], consider either raising an error when len(matches) > 1 and asking callers to disambiguate, or requiring an exact match in that case.

Suggested implementation:

        all_cols = [c for c in df.columns if c not in self._id_cols]

        if column is not None:
            matches = [c for c in all_cols if c == column or c.startswith(column)]
            if not matches:
                raise ValueError(
                    f"{column} not found in {attr}.\nAvailable types: {', '.join(all_cols)}"
                )

            # Prefer exact match if available; otherwise allow a single unambiguous prefix match.
            if column in matches:
                target_col = column
            elif len(matches) == 1:
                target_col = matches[0]
            else:
                # Multiple prefix matches and no exact match: require the caller to disambiguate.
                raise ValueError(
                    "Ambiguous column specification "
                    f"{column!r} for {attr}. "
                    f"Matches: {', '.join(matches)}. "
                    "Please provide a more specific or exact column name."
                )

            subset = df[target_col]
            if ids is not None:
                subset = subset.loc[df["id"].isin(ids)]

            if isinstance(subset, pd.DataFrame):
                result = subset.iloc[:, 0].tolist()
            else:
                result = subset.tolist()
        else:

@lyraluoyu

Copy link
Copy Markdown
Contributor Author

Closing this PR for now — I realized the issue originates from how the compressed JSON test data is generated rather than the dataset logic itself. I'll open a new PR focusing on fixing the test data generation.

@lyraluoyu lyraluoyu closed this Mar 28, 2026
@lyraluoyu
lyraluoyu deleted the fix-issue-robustness-plots branch March 31, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant