Repository: xgi-org/xgi

Issue:
Support DiHypergraph in from_bipartite_graph
Hi, do you think it's possible to support `DiHypergraph`s in this API?

https://github.com/xgi-org/xgi/blob/139a2bc5f1cff7f77090d1e7b882821833b7fe0b/xgi/convert/bipartite_graph.py#L12

I can try to implement it but I wonder what the API would look like. Likely something compatible with HIF.

Discussion:
@nwlandry: @colltoaction --- this is a great idea! Do you want to take the lead on this? If not, happy to give it a try.@colltoaction: Definitely! I'm thinking I can use the nx.Graph attributes like the HIF top-level metadata:

https://github.com/xgi-org/xgi/blob/0726fe1ca99b80fd57058d569ed8f6e1a2ee9b01/xgi/readwrite/hif.py#L138-L146

I would like to implement DiHypergraphs for from_bipartite at the moment, but there is more work to do for feature parity.

Solution patch:
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index 43a1e6521..64e4c433d 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -24,11 +24,11 @@ jobs:
           pip install .[benchmark]
       - name: Run benchmarks
         run: |
-          pytest benchmarks/*.py --benchmark-json benchmarks/output.json
+          pytest benchmarks/*.py --benchmark-json=benchmarks/output.json --benchmark-time-unit=ms   
       - name: Store benchmark results
         uses: benchmark-action/github-action-benchmark@v1
         with:
-          name: Python Benchmark with pytest-benchmark
+          name: XGI Benchmarks
           tool: 'pytest'
           output-file-path: benchmarks/output.json
           # Use personal access token instead of GITHUB_TOKEN due to https://github.community/t/github-action-not-triggering-gh-pages-upon-push/16096
diff --git a/.github/workflows/check-urls.yml b/.github/workflows/check-urls.yml
index ff0124aea..041d1a269 100644
--- a/.github/workflows/check-urls.yml
+++ b/.github/workflows/check-urls.yml
@@ -14,6 +14,8 @@ jobs:
         # A comma-separated list of file types to cover in the URL checks
         file_types: .md,.py,.rst,.ipynb
 
+        exclude_files: tools/*
+
         # Choose whether to include file with no URLs in the prints.
         print_all: false
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05e2f6917..fbe55d6e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## v0.9.3
+* Added the ability for `load_xgi_data` to read HIF and added HIF dicts to the convert module. [#613](https://github.com/xgi-org/xgi/pull/613) (@nwlandry)
+* Added a custom Changelog generator to use XGI formatting. [#626](https://github.com/xgi-org/xgi/pull/626) (@nwlandry)
+* Removed unnecesary asv files and added benchmarks. [#631](https://github.com/xgi-org/xgi/pull/631) (@nwlandry)
+* Added a benchmark GitHub Action with `pytest-benchmark`. [#630](https://github.com/xgi-org/xgi/pull/630) (@nwlandry)
+* Added release hashes for `asv`. [#628](https://github.com/xgi-org/xgi/pull/628) (@nwlandry)
+* [BREAKING] Replaced the `id` variable with `idx` in XGI (Fixes Issue [#619](https://github.com/xgi-org/xgi/issues/619)). [#620](https://github.com/xgi-org/xgi/pull/620) (@nwlandry)
+
 ## v0.9.2
 * Added nonuniform centralities and fixed the `node_edge_centrality` method [#600](https://github.com/xgi-org/xgi/pull/600) (@nwlandry).
 * Fixed Issue [#615](https://github.com/xgi-org/xgi/issues/615). Added a docstring to the `watts_strogatz_hypergraph` method [#618](https://github.com/xgi-org/xgi/pull/618) (@nwlandry).
diff --git a/HOW_TO_CONTRIBUTE.md b/HOW_TO_CONTRIBUTE.md
index a5fa79a95..e031fb313 100644
--- a/HOW_TO_CONTRIBUTE.md
+++ b/HOW_TO_CONTRIBUTE.md
@@ -9,7 +9,7 @@ Please note we have a [code of conduct](/CODE_OF_CONDUCT.md), please follow it i
 1. Download the dependencies in the developer [requirements file](/requirements/developer.txt).
 2. Add unit tests for features being added or bugs being fixed.
 3. Include any new method/function in the corresponding docs file.
-4. Run `pytest` to verify all unit tests pass.
+4. Run `pytest` to verify all unit tests pass. (To see what lines are covered, read the [`pytest-cov`](https://pytest-cov.readthedocs.io/en/latest/reporting.html) documentation.)
 5. [OPTIONAL] Format codebase according to the steps below.
 5. Submit Pull Request with a list of changes, links to issues that it addresses (if applicable)
 6. You may merge the Pull Request in once you have the sign-off of at least one other developer, or if you do not have permission to do that, you may request the reviewer to merge it for you.
@@ -27,7 +27,7 @@ Please note we have a [code of conduct](/CODE_OF_CONDUCT.md), please follow it i
 ## New Version process
 
 1. Make sure that the Github Actions workflow runs without any errors.
-2. Using the `github-changelog` package (a dependency in the [release](requirements/release.txt) requirements file), run `changelog -m xgi-org xgi [last release tag]` to get the merged pull requests with their links. Paste this into the changelog file under a new heading and edit to make more legible. Associate a GitHub username with each pull request.
+2. Run `python tools/generate_changelog.py -m xgi-org xgi [last release tag]` to get the merged pull requests with their links. Paste this into the changelog file under a new heading and edit to make more legible. Associate a GitHub username with each pull request.
 3. Increase the version number in [\_\_init\_\_.py](xgi/__init__.py.py) to the new version agreed upon by the core developers. The versioning scheme we use is [SemVer](http://semver.org/).
 4. Commit these changes.
 5. Create a new release on GitHub by selecting "Releases", then clicking "Draft a new release". Click "Choose a tag" and type "v" followed by the version number and then click "Create new tag". The release title will be this same string. Paste the contents of the CHANGELOG into the "Describe this release" field. Click "Publish release". This will trigger a GitHub action that will publish the new version on PyPI.
diff --git a/benchmarks/generators.py b/benchmarks/generators.py
index 58f8f1066..e9b990cfc 100644
--- a/benchmarks/generators.py
+++ b/benchmarks/generators.py
@@ -7,6 +7,6 @@
 def test_erdos_renyi(benchmark):
 
     def erdos_renyi():
-        xgi.fast_random_hypergraph(100, [0.1, 0.001])
+        xgi.random_hypergraph(100, [0.1, 0.001])
 
-    benchmark.pedantic(erdos_renyi, rounds=rounds, iterations=1)
+    benchmark.pedantic(erdos_renyi, rounds=rounds, iterations=iterations)
diff --git a/docs/source/api/convert.rst b/docs/source/api/convert.rst
index c78e1d519..d1565213a 100644
--- a/docs/source/api/convert.rst
+++ b/docs/source/api/convert.rst
@@ -11,6 +11,7 @@ convert package
    ~xgi.convert.bipartite_graph
    ~xgi.convert.encapsulation_dag
    ~xgi.convert.graph
+   ~xgi.convert.hif_dict
    ~xgi.convert.higher_order_network
    ~xgi.convert.hyperedges
    ~xgi.convert.hypergraph_dict
diff --git a/docs/source/api/convert/xgi.convert.hif_dict.rst b/docs/source/api/convert/xgi.convert.hif_dict.rst
new file mode 100644
index 000000000..0c835941b
--- /dev/null
+++ b/docs/source/api/convert/xgi.convert.hif_dict.rst
@@ -0,0 +1,11 @@
+xgi.convert.hif_dict
+====================
+
+.. currentmodule:: xgi.convert.hif_dict
+
+.. automodule:: xgi.convert.hif_dict
+   
+   .. rubric:: Functions
+   
+   .. autofunction:: to_hif_dict
+   .. autofunction:: from_hif_dict
\ No newline at end of file
diff --git a/docs/source/api/readwrite/xgi.readwrite.hif.rst b/docs/source/api/readwrite/xgi.readwrite.hif.rst
index 0f99b9c82..2aa2e7ca9 100644
--- a/docs/source/api/readwrite/xgi.readwrite.hif.rst
+++ b/docs/source/api/readwrite/xgi.readwrite.hif.rst
@@ -8,4 +8,6 @@ xgi.readwrite.hif
    .. rubric:: Functions
 
    .. autofunction:: read_hif
+   .. autofunction:: read_hif_collection
    .. autofunction:: write_hif
+   .. autofunction:: write_hif_collection
diff --git a/docs/source/using-xgi.rst b/docs/source/using-xgi.rst
index d38371bc7..5685e0f2b 100644
--- a/docs/source/using-xgi.rst
+++ b/docs/source/using-xgi.rst
@@ -22,6 +22,11 @@ Gonzalo Contreras-Aso, Regino Criado, and Miguel Romance, "Beyond directed hyper
 :bdg-link-primary-line:`Paper <https://doi.org/10.1093/comnet/cnae037>`
 :bdg-link-primary-line:`Code <https://github.com/goznalo-git/DirectedHyperCentrality>`
 
+Gonzalo Contreras-Aso, Cristian Pérez-Corral, and Miguel Romance, "Uplifting edges in higher order networks: spectral centralities for non-uniform hypergraphs", AIMS Mathematics, Volume 9, Issue 11: 32045-32075 (2024).
+
+:bdg-link-primary-line:`Paper <https://doi.org/10.3934/math.20241539>`
+:bdg-link-primary-line:`Code <https://github.com/LaComarca-Lab/non-uniform-hypergraphs>`
+
 Iacopo Iacopini, Márton Karsai, and Alain Barrat, "The temporal dynamics of group interactions in higher-order social networks", *Nature Communications* **15**, 7391 (2024).
 
 :bdg-link-primary-line:`Paper <https://doi.org/10.1038/s41467-024-50918-5>`
@@ -52,6 +57,7 @@ Yuanzhao Zhang, Per Sebastian Skardal, Federico Battiston, Giovanni Petri, and M
 :bdg-link-primary-line:`Paper <https://doi.org/10.1126/sciadv.ado8049>`
 :bdg-link-primary-line:`Code <https://github.com/maximelucas/basins_and_triangles>`
 
+
 2023
 ----
 
@@ -87,6 +93,10 @@ Preprints
 2024
 ----
 
+Gülşah Akçakır, Amina Azaiez, Alberto Ceria, Clara Eminente, Guglielmo Ferranti, Govind Gandhi, Aishvarya Raj, and Iacopo Iacopini, "Exploring the interplay of individual traits and interaction dynamics in preschool social networks", arXiv:2407.12728 (2024).
+
+:bdg-link-primary-line:`Paper <https://arxiv.org/abs/2407.12728>`
+
 Jordan Barrett, Paweł Prałat, Aaron Smith, François Théberge, "Counting simplicial pairs in hypergraphs", arXiv:2408.11806 (2024).
 
 :bdg-link-primary-line:`Paper <https://arxiv.org/abs/2408.11806>`
@@ -96,6 +106,11 @@ Robin Delabays, Giulia De Pasquale, Florian Dörfler, and Yuanzhao Zhang, "Hyper
 :bdg-link-primary-line:`Paper <https://arxiv.org/abs/2402.00078>`
 :bdg-link-primary-line:`Code <https://github.com/TaylorBasedHypergraphInference/THIS>`
 
+Laurent Hébert-Dufresne, Nicholas W. Landry, Juniper Lovato, Jonathan St-Onge, Jean-Gabriel Young, Marie-Ève Couture-Ménard, Stéphane Bernatchez, Catherine Choquette, and Alan A. Cohen, "Governance as a complex, networked, democratic, satisfiability problem", arXiv:2412.03421 (2024).
+
+:bdg-link-primary-line:`Paper <https://arxiv.org/abs/2412.03421>`
+:bdg-link-primary-line:`Code <https://github.com/nwlandry/governance>`
+
 Maxime Lucas, Luca Gallo, Arsham Ghavasieh, Federico Battiston, and Manlio De Domenico, "Functional reducibility of higher-order networks", arXiv:2404.08547 (2024).
 
 :bdg-link-primary-line:`Paper <https://arxiv.org/abs/2404.08547>`
@@ -105,14 +120,6 @@ Corbit R. Sampson, Juan G. Restrepo, "Competing Social Contagions with Opinion D
 
 :bdg-link-primary-line:`Paper <https://arxiv.org/abs/2408.10373>`
 
-2023
-----
-
-Gonzalo Contreras-Aso, Cristian Pérez-Corral, and Miguel Romance, "Uplifting edges in higher order networks: spectral centralities for non-uniform hypergraphs", arXiv:2310.20335 (2023).
-
-:bdg-link-primary-line:`Paper <https://arxiv.org/abs/2310.20335>`
-:bdg-link-primary-line:`Code <https://github.com/LaComarca-Lab/non-uniform-hypergraphs>`
-
 
 Theses
 ======
@@ -120,6 +127,10 @@ Theses
 2024
 ----
 
+Xie He, "Methods, Analyses, and Applications of Multilayer Temporal Link Prediction in Networks", (2024).
+
+:bdg-link-primary-line:`Thesis <https://digitalcommons.dartmouth.edu/dissertations/271/>`
+
 Yifei (Bell) Luo, "Efficient sampling of configuration model random hypergraphs", (2024).
 
 2023
diff --git a/docs/source/xgi-data.rst b/docs/source/xgi-data.rst
index 5b964bdc1..1d36ab925 100644
--- a/docs/source/xgi-data.rst
+++ b/docs/source/xgi-data.rst
@@ -30,7 +30,9 @@ See the `load_bigg_data() documentation <api/readwrite/xgi.readwrite.bigg_data.h
 Dataset format
 --------------
 
-The xgi-data format for higher-order datasets is a JSON data structure with the following structure:
+XGI-DATA provides higher-order datasets in two formats: (1) Hypergraph Interchange Format (HIF)-compliant JSON (2) XGI-specific JSON format. See the `HIF-standard <https://github.com/pszufe/HIF-standard>`_ documentation on format (1).
+All future datasets and updates to current datasets will be stored as format (1).
+Format (2) is structured as follows:
 
 * :code:`hypergraph-data`: This tag accesses the attributes of the entire hypergraph dataset such as the authors or dataset name.
 
diff --git a/pyproject.toml b/pyproject.toml
index 923ed88d0..d39a01a27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,7 +88,6 @@ release = [
     "twine>=3.4",
     "build>=1.2.1",
     "wheel>=0.36",
-    "github-changelog",
 ]
 test = [
     "pytest>=7.2",
diff --git a/requirements/release.txt b/requirements/release.txt
index acb0418ff..4e41020a0 100644
--- a/requirements/release.txt
+++ b/requirements/release.txt
@@ -3,4 +3,3 @@
 twine>=3.4
 build>=1.2.1
 wheel>=0.36
-github-changelog
diff --git a/tools/generate_changelog.py b/tools/generate_changelog.py
new file mode 100644
index 000000000..effe500c5
--- /dev/null
+++ b/tools/generate_changelog.py
@@ -0,0 +1,505 @@
+# -*- coding: utf-8 -*-
+"""
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
+
+Modified from https://github.com/cfpb/github-changelog
+
+This is a script to determine which PRs have been merges since the last
+release, or between two releases on the same branch.
+"""
+import argparse
+import os
+import re
+from collections import namedtuple
+from datetime import datetime, timedelta
+
+import requests
+
+DEFAULT_BRANCH = "main"
+PUBLIC_GITHUB_URL = "https://github.com"
+PUBLIC_GITHUB_API_URL = "https://api.github.com"
+GitHubConfig = namedtuple("GitHubConfig", ["base_url", "api_url", "headers"])
+
+Commit = namedtuple("Commit", ["sha", "message"])
+PullRequest = namedtuple(
+    "PullRequest", ["number", "title", "author", "closed_at", "associated_issues"]
+)
+
+# Merge commits use a double linebreak between the branch name and the title
+MERGE_PR_RE = re.compile(r"^Merge pull request #([0-9]+)*")
+
+# Squash-and-merge commits use the PR title with the number in parentheses
+SQUASH_PR_RE = re.compile(r".*\(#([0-9]+)\)*")
+
+
+class GitHubError(Exception):
+    pass
+
+
+def get_github_config(github_base_url, github_api_url, token):
+    """Returns a GitHubConfig instance based on the given arguments"""
+    if token is None:
+        token = os.environ.get("GITHUB_API_TOKEN")
+
+    headers = {}
+    if token is not None:
+        headers["Authorization"] = "token " + token
+
+    return GitHubConfig(
+        base_url=github_base_url, api_url=github_api_url, headers=headers
+    )
+
+
+def get_commit_for_tag(github_config, owner, repo, tag):
+    """Get the commit sha for a given git tag"""
+    tag_url = "/".join(
+        [
+            github_config.api_url,
+            "repos",
+            owner,
+            repo,
+            "git",
+            "refs",
+            "tags",
+            tag,
+        ]
+    )
+    tag_json = {}
+
+    while "object" not in tag_json or tag_json["object"]["type"] != "commit":
+        tag_response = requests.get(tag_url, headers=github_config.headers)
+        tag_json = tag_response.json()
+
+        if tag_response.status_code != 200:
+            raise GitHubError(f"Unable to get tag {tag}. {tag_json["message"]}")
+
+        # If we're given a tag object we have to look up the commit
+        if tag_json["object"]["type"] == "tag":
+            tag_url = tag_json["object"]["url"]
+
+    return tag_json["object"]["sha"]
+
+
+def get_last_commit(github_config, owner, repo, branch=DEFAULT_BRANCH):
+    """Get the last commit sha for the given repo and branch"""
+    commits_url = "/".join([github_config.api_url, "repos", owner, repo, "commits"])
+    commits_response = requests.get(
+        commits_url, params={"sha": branch}, headers=github_config.headers
+    )
+    commits_json = commits_response.json()
+    if commits_response.status_code != 200:
+        raise GitHubError(f"Unable to get commits. {commits_json["message"]}")
+
+    return commits_json[0]["sha"]
+
+
+def get_last_tag(github_config, owner, repo):
+    """Get the last tag for the given repo"""
+    tags_url = "/".join([github_config.api_url, "repos", owner, repo, "tags"])
+    tags_response = requests.get(tags_url, headers=github_config.headers)
+    tags_response.raise_for_status()
+    tags_json = tags_response.json()
+    return tags_json[0]["name"]
+
+
+def get_commits_between(github_config, owner, repo, first_commit, last_commit):
+    """Get a list of commits between two commits"""
+    commits_url = "/".join(
+        [
+            github_config.api_url,
+            "repos",
+            owner,
+            repo,
+            "compare",
+            first_commit + "..." + last_commit,
+        ]
+    )
+    commits_response = requests.get(commits_url, headers=github_config.headers)
+    commits_json = commits_response.json()
+    if commits_response.status_code != 200:
+        raise GitHubError(
+            f"Unable to get commits between {first_commit} and {last_commit}. {commits_json["message"]}"
+        )
+
+    if "commits" not in commits_json:
+        raise GitHubError(
+            f"Commits not found between {first_commit} and {last_commit}."
+        )
+
+    commits = [
+        Commit(c["sha"], c["commit"]["message"]) for c in commits_json["commits"]
+    ]
+    return commits
+
+
+def is_pr(message):
+    """Determine whether or not a commit message is a PR merge"""
+    return MERGE_PR_RE.search(message) or SQUASH_PR_RE.search(message)
+
+
+def extract_pr_number(message):
+    """Given a PR merge commit message, extract the PR number and title"""
+    merge_match = MERGE_PR_RE.match(message)
+    squash_match = SQUASH_PR_RE.match(message)
+
+    if merge_match is not None:
+        numbers = merge_match.groups()
+        return numbers[-1]
+    elif squash_match is not None:
+        numbers = squash_match.groups()
+
+        return numbers[-1]  # PullRequest(number=number, title=title, author=author)
+
+    raise Exception(f"Commit isn't a PR merge, {message}")
+
+
+def prs_from_numbers(github_config, owner, repo, pr_numbers):
+    pr_list = []
+    for number in pr_numbers:
+        pull_url = "/".join(
+            [
+                github_config.api_url,
+                "repos",
+                owner,
+                repo,
+                "pulls",
+                number,
+            ]
+        )
+        pull_response = requests.get(pull_url, headers=github_config.headers)
+        pull_json = pull_response.json()
+        title = pull_json["title"]
+        author = pull_json["user"]["login"]
+        closed_at = pull_json["closed_at"]
+        pr_list.append(
+            PullRequest(
+                number=number,
+                title=title,
+                author=author,
+                closed_at=closed_at,
+                associated_issues=[],
+            )
+        )
+    return pr_list
+
+
+def get_associated_issues(github_config, owner, repo, prs):
+    issues_url = "/".join(
+        [
+            github_config.api_url,
+            "repos",
+            owner,
+            repo,
+            "issues?state=closed",
+        ]
+    )
+    issues_response = requests.get(issues_url, headers=github_config.headers)
+    issues_json = issues_response.json()
+    issues = {entry["number"]: entry["closed_at"] for entry in issues_json}
+    for pr in prs:
+        t_pr = datetime.fromisoformat(pr.closed_at)
+        for i in issues:
+            t_issue = datetime.fromisoformat(issues[i])
+            if abs((t_issue - t_pr).total_seconds()) <= 2 and int(i) != int(pr.number):
+                pr.associated_issues.append(i)
+
+
+def fetch_changes(
+    github_config,
+    owner,
+    repo,
+    previous_tag=None,
+    current_tag=None,
+    branch=DEFAULT_BRANCH,
+):
+    if previous_tag is None:
+        previous_tag = get_last_tag(github_config, owner, repo)
+    previous_commit = get_commit_for_tag(github_config, owner, repo, previous_tag)
+
+    current_commit = None
+    if current_tag is not None:
+        try:
+            current_commit = get_commit_for_tag(github_config, owner, repo, current_tag)
+        except GitHubError:
+            # Try to proceed with the given "tag" as a commit sha
+            current_commit = current_tag
+    else:
+        current_commit = get_last_commit(github_config, owner, repo, branch)
+
+    commits_between = get_commits_between(
+        github_config, owner, repo, previous_commit, current_commit
+    )
+
+    # Process the commit list looking for PR merges
+    pr_numbers = [
+        extract_pr_number(c.message) for c in commits_between if is_pr(c.message)
+    ]
+
+    if len(pr_numbers) == 0 and len(commits_between) > 0:
+        raise Exception("Lots of commits and no PRs on branch {branch}")
+    else:
+        prs = prs_from_numbers(github_config, owner, repo, pr_numbers)
+
+    prs.reverse()
+    return prs
+
+
+def format_changes(github_config, owner, repo, prs, markdown=True):
+    """Format the list of prs in either text or markdown"""
+    lines = []
+    for pr in prs:
+        pr_number = f"#{pr.number}"
+        if markdown:
+            pr_link = f"{github_config.base_url}/{owner}/{repo}/pull/{pr.number}"
+            pr_number = f"[{pr_number}]({pr_link})"
+
+        issues = pr.associated_issues
+
+        if len(issues) == 0:
+            issues_string = ""
+
+        elif len(issues) == 1:
+            issue_number = issues[0]
+            issue_link = (
+                f"{github_config.base_url}/{owner}/{repo}/issues/{issue_number}"
+            )
+            issues_string = f"(Closes Issue [#{issue_number}]({issue_link})) "
+
+        elif len(issues) == 2:
+            issue_number1, issue_number2 = issues
+            issue_link1 = (
+                f"{github_config.base_url}/{owner}/{repo}/issues/{issue_number1}"
+            )
+            issue_link2 = (
+                f"{github_config.base_url}/{owner}/{repo}/issues/{issue_number2}"
+            )
+            issues_string = f"(Closes Issues [#{issue_number1}]({issue_link1}) and [#{issue_number2}]({issue_link2})) "
+        else:
+            issues_string = "(Closes Issues "
+            for i, issue_number in enumerate(issues):
+                issue_link = (
+                    f"{github_config.base_url}/{owner}/{repo}/issues/{issue_number}"
+                )
+
+                if i < len(issues):
+                    issues_string += f"[#{issue_number}]({issue_link}), "
+                else:
+                    issues_string += f"and [#{issue_number}]({issue_link})) "
+
+        lines.append(f"* {pr.title}. {issues_string}{pr_number} (@{pr.author})")
+
+    return lines
+
+
+def generate_changelog(
+    owner,
+    repo,
+    previous_tag=None,
+    current_tag=None,
+    markdown=True,
+    single_line=False,
+    branch=None,
+    github_base_url=None,
+    github_api_url=None,
+    github_token=None,
+):
+
+    github_config = get_github_config(github_base_url, github_api_url, github_token)
+
+    prs = fetch_changes(github_config, owner, repo, previous_tag, current_tag, branch)
+    get_associated_issues(github_config, owner, repo, prs)
+    lines = format_changes(github_config, owner, repo, prs, markdown=markdown)
+
+    separator = "\\n" if single_line else "\n"
+    return separator.join(lines)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate a CHANGELOG between two git tags based on GitHub"
+        "Pull Request merge commit messages"
+    )
+    parser.add_argument("owner", metavar="OWNER", help="owner of the repo on GitHub")
+    parser.add_argument("repo", metavar="REPO", help="name of the repo on GitHub")
+    parser.add_argument(
+        "previous_tag",
+        metavar="PREVIOUS",
+        nargs="?",
+        help="previous release tag (defaults to last tag)",
+    )
+    parser.add_argument(
+        "current_tag",
+        metavar="CURRENT",
+        nargs="?",
+        help="current release tag (defaults to HEAD)",
+    )
+    parser.add_argument(
+        "-m", "--markdown", action="store_true", help="output in markdown"
+    )
+    parser.add_argument(
+        "-s",
+        "--single-line",
+        action="store_true",
+        help="output as single line joined by \\n characters",
+    )
+    parser.add_argument(
+        "--branch",
+        type=str,
+        action="store",
+        default=DEFAULT_BRANCH,
+        help="Override the " "target branch (defaults to main)",
+    )
+    parser.add_argument(
+        "--github-base-url",
+        type=str,
+        action="store",
+        default=PUBLIC_GITHUB_URL,
+        help="Override if you "
+        "are using GitHub Enterprise. e.g. https://github."
+        "my-company.com",
+    )
+    parser.add_argument(
+        "--github-api-url",
+        type=str,
+        action="store",
+        default=PUBLIC_GITHUB_API_URL,
+        help="Override if you "
+        "are using GitHub Enterprise. e.g. https://github."
+        "my-company.com/api/v3",
+    )
+    parser.add_argument(
+        "--github-token",
+        type=str,
+        action="store",
+        default=None,
+        help="GitHub oauth token to auth " "your Github requests with",
+    )
+
+    args = parser.parse_args()
+
+    changelog = generate_changelog(**vars(args))
+    print(changelog)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tutorials/case_studies/comparing_centralities.ipynb b/tutorials/case_studies/comparing_centralities.ipynb
index acc22d758..08cb22642 100644
--- a/tutorials/case_studies/comparing_centralities.ipynb
+++ b/tutorials/case_studies/comparing_centralities.ipynb
@@ -15,9 +15,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import xgi\n",
+    "import pandas as pd\n",
     "import seaborn as sns\n",
-    "import pandas as pd"
+    "\n",
+    "import xgi"
    ]
   },
   {
diff --git a/xgi/__init__.py b/xgi/__init__.py
index a8d0a496b..888d87614 100644
--- a/xgi/__init__.py
+++ b/xgi/__init__.py
@@ -22,4 +22,4 @@
 from .stats import *
 
 
-__version__ = "0.9.2"
+__version__ = "0.9.3"
diff --git a/xgi/convert/__init__.py b/xgi/convert/__init__.py
index 84340a080..8a5f38bf9 100644
--- a/xgi/convert/__init__.py
+++ b/xgi/convert/__init__.py
@@ -3,6 +3,7 @@
     bipartite_graph,
     encapsulation_dag,
     graph,
+    hif_dict,
     higher_order_network,
     hyperedges,
     hypergraph_dict,
@@ -15,6 +16,7 @@
 from .bipartite_graph import *
 from .encapsulation_dag import *
 from .graph import *
+from .hif_dict import *
 from .higher_order_network import *
 from .hyperedges import *
 from .hypergraph_dict import *
diff --git a/xgi/convert/bipartite_graph.py b/xgi/convert/bipartite_graph.py
index 874424bbd..ed0cb270d 100644
--- a/xgi/convert/bipartite_graph.py
+++ b/xgi/convert/bipartite_graph.py
@@ -1,15 +1,14 @@
 """Methods for converting to and from bipartite graphs."""
 
 import networkx as nx
-from networkx import bipartite
 
+from ..core import DiHypergraph, Hypergraph
 from ..exception import XGIError
-from ..generators import empty_hypergraph
 
 __all__ = ["from_bipartite_graph", "to_bipartite_graph"]
 
 
-def from_bipartite_graph(G, create_using=None, dual=False):
+def from_bipartite_graph(G, dual=False):
     """
     Create a Hypergraph from a NetworkX bipartite graph.
 
@@ -30,16 +29,13 @@ def from_bipartite_graph(G, create_using=None, dual=False):
         A networkx bipartite graph. Each node in the graph has a property
         'bipartite' taking the value of 0 or 1 indicating the type of node.
 
-    create_using : Hypergraph constructor, optional
-        The hypergraph object to add the data to, by default None
-
     dual : bool, default : False
         If True, get edges from bipartite=0 and nodes from bipartite=1
 
     Returns
     -------
-    Hypergraph
-        The equivalent hypergraph
+    Hypergraph or DiHypergraph
+        The equivalent hypergraph or directed hypergraph
 
     References
     ----------
@@ -58,8 +54,14 @@ def from_bipartite_graph(G, create_using=None, dual=False):
     >>> H = xgi.from_bipartite_graph(G)
 
     """
+    if isinstance(G, nx.DiGraph):
+        directed = True
+    else:
+        directed = False
+
     edges = []
     nodes = []
+
     for n, d in G.nodes(data=True):
         try:
             node_type = d["bipartite"]
@@ -73,23 +75,44 @@ def from_bipartite_graph(G, create_using=None, dual=False):
         else:
             raise XGIError("Invalid type specifier")
 
-    if not bipartite.is_bipartite_node_set(G, nodes):
+    if not _is_bipartite(G, nodes, edges):
         raise XGIError("The network is not bipartite")
 
-    H = empty_hypergraph(create_using)
+    if directed:
+        H = DiHypergraph()
+    else:
+        H = Hypergraph()
+
     H.add_nodes_from(nodes)
-    for edge in edges:
-        nodes_in_edge = list(G.neighbors(edge))
-        H.add_edge(nodes_in_edge, idx=edge)
+
+    for u, v in G.edges:
+        if directed:
+            if v in edges:
+                H.add_node_to_edge(v, u, direction="in")
+            else:
+                H.add_node_to_edge(u, v, direction="out")
+        else:
+            H.add_node_to_edge(v, u)
+
     return H.dual() if dual else H
 
 
+def _is_bipartite(G, nodes1, nodes2):
+    """Assumption is that nodes1.union(nodes2) == G.nodes"""
+    for i, j in G.edges:
+        cond1 = i in nodes1
+        cond2 = j in nodes2
+        if not cond1 == cond2:  # if not both true or both false
+            return False
+    return True
+
+
 def to_bipartite_graph(H, index=False):
     """Create a NetworkX bipartite network from a hypergraph.
 
     Parameters
     ----------
-    H: xgi.Hypergraph
+    H: xgi.Hypergraph or xgi.DiHypergraph
         The XGI hypergraph object of interest
     index: bool (default False)
         If False (default), return only the graph.  If True, additionally return the
@@ -97,10 +120,14 @@ def to_bipartite_graph(H, index=False):
 
     Returns
     -------
-    nx.Graph[, dict, dict]
-        The resulting equivalent bipartite graph, and optionally the index-to-unit
-        mappings.
-
+    if xgi.Hypergraph
+        nx.Graph[, dict, dict]
+            The resulting equivalent bipartite graph, and optionally the index-to-unit
+            mappings.
+    if xgi.Hypergraph
+        nx.DiGraph[, dict, dict]
+            The resulting equivalent directed bipartite graph, and optionally the index-to-unit
+            mappings.
     References
     ----------
     The Why, How, and When of Representations for Complex Systems,
@@ -116,24 +143,41 @@ def to_bipartite_graph(H, index=False):
     >>> G, itn, ite = xgi.to_bipartite_graph(H, index=True)
 
     """
-    G = nx.Graph()
+    if isinstance(H, DiHypergraph):
+        directed = True
+    else:
+        directed = False
 
     n = H.num_nodes
     m = H.num_edges
 
     node_dict = dict(zip(H.nodes, range(n)))
     edge_dict = dict(zip(H.edges, range(n, n + m)))
+
+    if directed:
+        G = nx.DiGraph()
+    else:
+        G = nx.Graph()
+
     G.add_nodes_from(node_dict.values(), bipartite=0)
     G.add_nodes_from(edge_dict.values(), bipartite=1)
-    for node in H.nodes:
-        for edge in H.nodes.memberships(node):
-            G.add_edge(node_dict[node], edge_dict[edge])
+
+    if directed:
+        for e in H.edges:
+            for v in H.edges.tail(e):
+                G.add_edge(node_dict[v], edge_dict[e])
+            for v in H.edges.head(e):
+                G.add_edge(edge_dict[e], node_dict[v])
+    else:
+        for e in H.edges:
+            for v in H.edges.members(e):
+                G.add_edge(node_dict[v], edge_dict[e])
 
     if index:
         return (
             G,
-            dict(zip(range(n), H.nodes)),
-            dict(zip(range(n, n + m), H.edges)),
+            {v: k for k, v in node_dict.items()},
+            {v: k for k, v in edge_dict.items()},
         )
     else:
         return G
diff --git a/xgi/convert/hif_dict.py b/xgi/convert/hif_dict.py
new file mode 100644
index 000000000..f137f057c
--- /dev/null
+++ b/xgi/convert/hif_dict.py
@@ -0,0 +1,159 @@
+"""Methods for converting to/from HIF standard."""
+
+from collections import defaultdict
+
+from ..core import DiHypergraph, Hypergraph, SimplicialComplex
+from ..utils import IDDict
+from .bipartite_edges import to_bipartite_edgelist
+
+__all__ = ["to_hif_dict", "from_hif_dict"]
+
+
+def to_hif_dict(H):
+    """
+    A function to create a dictionary according to the HIF standard from a higher-order network.
+
+    For more information, see the HIF `project <https://github.com/pszufe/HIF_validators>`_.
+
+    Parameters
+    ----------
+    H: Hypergraph, DiHypergraph, or SimplicialComplex object
+        The specified higher-order network
+
+    Returns
+    -------
+    defaultdict
+        A dict according to the HIF standard.
+    """
+    data = defaultdict(list)
+
+    data["metadata"] = {}
+    data["metadata"].update(H._net_attr)
+
+    if isinstance(H, SimplicialComplex):
+        data["network-type"] = "asc"
+    elif isinstance(H, Hypergraph):
+        data["network-type"] = "undirected"
+    elif isinstance(H, DiHypergraph):
+        data["network-type"] = "directed"
+
+    # get node data
+    isolates = set(H.nodes.isolates())
+    nodes_with_attrs = set(n for n in H.nodes if H.nodes[n])
+    for n in isolates.union(nodes_with_attrs):
+        attr = {"attrs": H.nodes[n]} if H.nodes[n] else {}
+        data["nodes"].append(IDDict({"node": n}) + attr)
+
+    empty = set(H.edges.empty())
+    edges_with_attrs = set(e for e in H.edges if H.edges[e])
+    for e in empty.union(edges_with_attrs):
+        attr = {"attrs": H.edges[e]} if H.edges[e] else {}
+        data["edges"].append(IDDict({"edge": e}) + attr)
+
+    # hyperedge dict
+    if data["network-type"] == "directed":
+        _convert_d = lambda d: "tail" if d == "in" else "head"
+        data["incidences"] = [
+            IDDict({"edge": e, "node": n, "direction": _convert_d(d)})
+            for n, e, d in to_bipartite_edgelist(H)
+        ]
+    elif data["network-type"] in {"undirected", "asc"}:
+        data["incidences"] = [
+            IDDict({"edge": e, "node": n}) for n, e in to_bipartite_edgelist(H)
+        ]
+    return data
+
+
+def from_hif_dict(data, nodetype=None, edgetype=None):
+    """
+    A function to read a dictionary that follows the HIF standard.
+
+    For more information, see the HIF `project <https://github.com/pszufe/HIF_validators>`_.
+
+    Parameters
+    ----------
+    data: dict
+        A dictionary in the hypergraph JSON format
+    nodetype: type, optional
+        Type that the node IDs will be cast to
+    edgetype: type, optional
+        Type that the edge IDs will be cast to
+
+    Returns
+    -------
+    A Hypergraph, SimplicialComplex, or DiHypergraph object
+        The loaded network
+    """
+
+    def _empty_edge(network_type):
+        if network_type in {"asc", "undirected"}:
+            return set()
+        else:
+            return (set(), set())
+
+    def _convert_id(i, idtype):
+        if idtype:
+            try:
+                return idtype(i)
+            except ValueError as e:
+                raise TypeError(f"Failed to convert ID {i} to type {idtype}.") from e
+        else:
+            return i
+
+    _convert_d = lambda d: "in" if d == "tail" else "out"
+
+    if "network-type" in data:
+        network_type = data["network-type"]
+    else:
+        network_type = "undirected"
+
+    if network_type in {"asc", "undirected"}:
+        H = Hypergraph()
+    elif network_type == "directed":
+        H = DiHypergraph()
+
+    # Import network metadata
+    if "metadata" in data:
+        H._net_attr.update(data["metadata"])
+
+    for record in data["incidences"]:
+        n = _convert_id(record["node"], nodetype)
+        e = _convert_id(record["edge"], edgetype)
+
+        if network_type == "directed":
+            d = record["direction"]
+            d = _convert_d(d)  # convert from head/tail to in/out
+            H.add_node_to_edge(e, n, d)
+        else:
+            H.add_node_to_edge(e, n)
+
+    # import node attributes if they exist
+    if "nodes" in data:
+        for record in data["nodes"]:
+            n = _convert_id(record["node"], nodetype)
+            if "attrs" in record:
+                attr = record["attrs"]
+            else:
+                attr = {}
+
+            if n not in H._node:
+                H.add_node(n, **attr)
+            else:
+                H.set_node_attributes({n: attr})
+
+    # import edge attributes if they exist
+    if "edges" in data:
+        for record in data["edges"]:
+            e = _convert_id(record["edge"], edgetype)
+            if "attrs" in record:
+                attr = record["attrs"]
+            else:
+                attr = {}
+            if e not in H._edge:
+                H.add_edge(_empty_edge(network_type), e, **attr)
+            else:
+                H.set_edge_attributes({e: attr})
+
+    if network_type == "asc":
+        H = SimplicialComplex(H)
+    return H
diff --git a/xgi/generators/randomizing.py b/xgi/generators/randomizing.py
index 51e3ecd10..de4b497cb 100644
--- a/xgi/generators/randomizing.py
+++ b/xgi/generators/randomizing.py
@@ -15,7 +15,7 @@
 ]
 
 
-def shuffle_hyperedges(S, order, p):
+def shuffle_hyperedges(S, order, p, seed=None):
     """Shuffle existing hyperdeges of order `order` with probablity `p`.
 
     Parameters
@@ -26,6 +26,8 @@ def shuffle_hyperedges(S, order, p):
         Order of hyperedges to shuffle
     p : float
         Probability of shuffling each hyperedge
+    seed : integer or None (default)
+            Seed for the random number generator.
 
     Returns
     -------
@@ -52,6 +54,9 @@ def shuffle_hyperedges(S, order, p):
 
     """
 
+    if seed is not None:
+        random.seed(seed)
+
     if (order + 1) not in xgi.unique_edge_sizes(S):
         raise ValueError(f"There is no hyperedge of order {order} is this hypergraph.")
     if (p < 0) or (p > 1):
diff --git a/xgi/readwrite/hif.py b/xgi/readwrite/hif.py
index 9d00ee1f9..1b8dec9c4 100644
--- a/xgi/readwrite/hif.py
+++ b/xgi/readwrite/hif.py
@@ -6,15 +6,15 @@
 
 import json
 from collections import defaultdict
+from os.path import dirname, join
 
-from ..convert import to_bipartite_edgelist
-from ..core import DiHypergraph, Hypergraph, SimplicialComplex
-from ..utils import IDDict
+from ..convert import from_hif_dict, to_hif_dict
+from ..exception import XGIError
 
-__all__ = ["write_hif", "read_hif"]
+__all__ = ["write_hif", "write_hif_collection", "read_hif", "read_hif_collection"]
 
 
-def write_hif(G, path):
+def write_hif(H, path):
     """
     A function to write a higher-order network according to the HIF standard.
 
@@ -22,48 +22,12 @@ def write_hif(G, path):
 
     Parameters
     ----------
-    G: Hypergraph, DiHypergraph, or SimplicialComplex object
+    H: Hypergraph, DiHypergraph, or SimplicialComplex object
         The specified higher-order network
     path: string
         The path of the file to read from
     """
-    # initialize empty data
-    data = defaultdict(list)
-
-    data["metadata"] = {}
-    data["metadata"].update(G._net_attr)
-
-    if isinstance(G, SimplicialComplex):
-        data["network-type"] = "asc"
-    elif isinstance(G, Hypergraph):
-        data["network-type"] = "undirected"
-    elif isinstance(G, DiHypergraph):
-        data["network-type"] = "directed"
-
-    # get node data
-    isolates = set(G.nodes.isolates())
-    nodes_with_attrs = set(n for n in G.nodes if G.nodes[n])
-    for n in isolates.union(nodes_with_attrs):
-        attr = {"attrs": G.nodes[n]} if G.nodes[n] else {}
-        data["nodes"].append(IDDict({"node": n}) + attr)
-
-    empty = set(G.edges.empty())
-    edges_with_attrs = set(e for e in G.edges if G.edges[e])
-    for e in empty.union(edges_with_attrs):
-        attr = {"attrs": G.edges[e]} if G.edges[e] else {}
-        data["edges"].append(IDDict({"edge": e}) + attr)
-
-    # hyperedge dict
-    if data["network-type"] == "directed":
-        _convert_d = lambda d: "tail" if d == "in" else "head"
-        data["incidences"] = [
-            IDDict({"edge": e, "node": n, "direction": _convert_d(d)})
-            for n, e, d in to_bipartite_edgelist(G)
-        ]
-    elif data["network-type"] in {"undirected", "asc"}:
-        data["incidences"] = [
-            IDDict({"edge": e, "node": n}) for n, e in to_bipartite_edgelist(G)
-        ]
+    data = to_hif_dict(H)
 
     datastring = json.dumps(data, indent=2)
 
@@ -71,6 +35,50 @@ def write_hif(G, path):
         output_file.write(datastring)
 
 
+def write_hif_collection(H, path, collection_name=""):
+    """
+    A function to write a collection of higher-order network according to the HIF standard.
+
+    For more information, see the HIF `project <https://github.com/pszufe/HIF_validators>`_.
+
+    Parameters
+    ----------
+    H: list or dict of Hypergraph, DiHypergraph, or SimplicialComplex objects
+        The specified higher-order network
+    path: string
+        The path of the file to read from
+    """
+    if isinstance(H, list):
+        collection_data = defaultdict(dict)
+        for i, H in enumerate(H):
+            fname = f"{path}/{collection_name}_{i}.json"
+            collection_data["datasets"][i] = {
+                "relative-path": f"{collection_name}_{i}.json"
+            }
+            write_hif(H, fname)
+        collection_data["type"] = "collection"
+        datastring = json.dumps(collection_data, indent=2)
+        with open(
+            f"{path}/{collection_name}_collection_information.json", "w"
+        ) as output_file:
+            output_file.write(datastring)
+
+    elif isinstance(H, dict):
+        collection_data = defaultdict(dict)
+        for name, H in H.items():
+            fname = f"{path}/{collection_name}_{name}.json"
+            collection_data["datasets"][name] = {
+                "relative-path": f"{collection_name}_{name}.json"
+            }
+            write_hif(H, fname)
+        collection_data["type"] = "collection"
+        datastring = json.dumps(collection_data, indent=2)
+        with open(
+            f"{path}/{collection_name}_collection_information.json", "w"
+        ) as output_file:
+            output_file.write(datastring)
+
+
 def read_hif(path, nodetype=None, edgetype=None):
     """
     A function to read a file created according to the HIF format.
@@ -79,8 +87,8 @@ def read_hif(path, nodetype=None, edgetype=None):
 
     Parameters
     ----------
-    data: dict
-        A dictionary in the hypergraph JSON format
+    path: str
+        The path to the json file
     nodetype: type, optional
         type that the node IDs will be cast to
     edgetype: type, optional
@@ -94,99 +102,44 @@ def read_hif(path, nodetype=None, edgetype=None):
     with open(path) as file:
         data = json.loads(file.read())
 
-    return _from_dict(data, nodetype=nodetype, edgetype=edgetype)
+    return from_hif_dict(data, nodetype=nodetype, edgetype=edgetype)
 
 
-def _from_dict(data, nodetype=None, edgetype=None):
+def read_hif_collection(path, nodetype=None, edgetype=None):
     """
-    A helper function to read a file created according to the HIF format.
+    A function to read a collection of files created according to the HIF format.
+
+    There must be a collection information JSON file which has a top-level field "datasets"
+    with subfields "relative-path", indicating each dataset's location relative to the
+    collection file
 
     For more information, see the HIF `project <https://github.com/pszufe/HIF_validators>`_.
 
     Parameters
     ----------
-    data: dict
-        A dictionary in the hypergraph JSON format
+    path: str
+        A path to the collection json file.
     nodetype: type, optional
-        Type that the node IDs will be cast to
+        type that the node IDs will be cast to
     edgetype: type, optional
-        Type that the edge IDs will be cast to
+        type that the edge IDs will be cast to
 
     Returns
     -------
-    A Hypergraph, SimplicialComplex, or DiHypergraph object
-        The loaded network
+    A dictionary of Hypergraph, SimplicialComplex, or DiHypergraph objects
+        The collection of networks
     """
-
-    def _empty_edge(network_type):
-        if network_type in {"asc", "undirected"}:
-            return set()
-        else:
-            return (set(), set())
-
-    def _convert_id(i, idtype):
-        if idtype:
-            try:
-                return idtype(i)
-            except ValueError as e:
-                raise TypeError(f"Failed to convert ID {i} to type {idtype}.") from e
-        else:
-            return i
-
-    _convert_d = lambda d: "in" if d == "tail" else "out"
-
-    if "network-type" in data:
-        network_type = data["network-type"]
-    else:
-        network_type = "undirected"
-
-    if network_type in {"asc", "undirected"}:
-        G = Hypergraph()
-    elif network_type == "directed":
-        G = DiHypergraph()
-
-    # Import network metadata
-    if "metadata" in data:
-        G._net_attr.update(data["metadata"])
-
-    for record in data["incidences"]:
-        n = _convert_id(record["node"], nodetype)
-        e = _convert_id(record["edge"], edgetype)
-
-        if network_type == "directed":
-            d = record["direction"]
-            d = _convert_d(d)  # convert from head/tail to in/out
-            G.add_node_to_edge(e, n, d)
-        else:
-            G.add_node_to_edge(e, n)
-
-    # import node attributes if they exist
-    if "nodes" in data:
-        for record in data["nodes"]:
-            n = _convert_id(record["node"], nodetype)
-            if "attrs" in record:
-                attr = record["attrs"]
-            else:
-                attr = {}
-
-            if n not in G._node:
-                G.add_node(n, **attr)
-            else:
-                G.set_node_attributes({n: attr})
-
-    # import edge attributes if they exist
-    if "edges" in data:
-        for record in data["edges"]:
-            e = _convert_id(record["edge"], edgetype)
-            if "attrs" in record:
-                attr = record["attrs"]
-            else:
-                attr = {}
-            if e not in G._edge:
-                G.add_edge(_empty_edge(network_type), e, **attr)
-            else:
-                G.set_edge_attributes({e: attr})
-
-    if network_type == "asc":
-        G = SimplicialComplex(G)
-    return G
+    with open(path) as file:
+        jsondata = json.loads(file.read())
+
+    try:
+        collection = {}
+        for name, data in jsondata["datasets"].items():
+            relpath = data["relative-path"]
+            H = read_hif(
+                join(dirname(path), relpath), nodetype=nodetype, edgetype=edgetype
+            )
+            collection[name] = H
+        return collection
+    except KeyError:
+        raise XGIError("Data collection is in the wrong format!")
diff --git a/xgi/readwrite/json.py b/xgi/readwrite/json.py
index 8de683c6b..3493aac95 100644
--- a/xgi/readwrite/json.py
+++ b/xgi/readwrite/json.py
@@ -3,6 +3,7 @@
 import json
 from collections import defaultdict
 from os.path import dirname, join
+from warnings import warn
 
 from ..convert import from_hypergraph_dict, to_hypergraph_dict
 from ..core import Hypergraph, SimplicialComplex
@@ -36,6 +37,7 @@ def write_json(H, path, collection_name=""):
         to strings, e.g., node IDs "2" and 2.
 
     """
+    warn("This function is deprecated in favor of the 'write_hif()' function")
     if collection_name:
         collection_name += "_"
 
@@ -101,6 +103,7 @@ def read_json(path, nodetype=None, edgetype=None):
         If the JSON is not in a format that can be loaded.
 
     """
+    warn("This function is deprecated in favor of the 'read_hif()' function")
     with open(path) as file:
         jsondata = json.loads(file.read())
 
diff --git a/xgi/readwrite/xgi_data.py b/xgi/readwrite/xgi_data.py
index 4fe2f9d29..a35baa335 100644
--- a/xgi/readwrite/xgi_data.py
+++ b/xgi/readwrite/xgi_data.py
@@ -3,7 +3,7 @@
 from os.path import dirname, exists, join
 from warnings import warn
 
-from ..convert import from_hypergraph_dict
+from ..convert import cut_to_order, from_hif_dict, from_hypergraph_dict
 from ..exception import XGIError
 from ..utils import request_json_from_url, request_json_from_url_cached
 
@@ -164,6 +164,12 @@ def _request_from_xgi_data(
     else:
         jsondata = request_json_from_url(url)
 
+    if "incidences" in jsondata:
+        H = from_hif_dict(jsondata, nodetype=nodetype, edgetype=edgetype)
+        if max_order:
+            H = cut_to_order(H, order=max_order)
+        return H
+
     if "type" in jsondata and jsondata["type"] == "collection":
         collection = {}
         for name, data in jsondata["datasets"].items():
diff --git a/xgi/stats/__init__.py b/xgi/stats/__init__.py
index 5e0e06481..c327ee941 100644
--- a/xgi/stats/__init__.py
+++ b/xgi/stats/__init__.py
@@ -180,7 +180,6 @@ def ashist(self, bins=10, bin_edges=False, density=False, log_binning=False):
             Whether to bin the values with log-sized bins.
             By default, False.
 
-
         Returns
         -------
         Pandas DataFrame
@@ -189,6 +188,11 @@ def ashist(self, bins=10, bin_edges=False, density=False, log_binning=False):
             is True, outputs two additional columns, `bin_lo` and `bin_hi`,
             which outputs the left and right bin edges respectively.
 
+            The DataFrame includes the following attributes:
+                - attrs['xlabel']: Label for x-axis
+            - attrs['ylabel']: 'Count' or 'Probability' based on density parameter
+            - attrs['title']: Plot title
+
         Notes
         -----
         Originally from https://github.com/jkbren/networks-and-dataviz
@@ -199,7 +203,17 @@ def ashist(self, bins=10, bin_edges=False, density=False, log_binning=False):
         if isinstance(bins, int) and len(set(self.aslist())) == 1:
             bins = 1
 
-        return hist(self.asnumpy(), bins, bin_edges, density, log_binning)
+        # My modifications below
+
+        # Get the histogram Dataframe
+        df = hist(self.asnumpy(), bins, bin_edges, density, log_binning)
+
+        # Add metadata attributes
+        df.attrs["xlabel"] = "Value"
+        df.attrs["ylabel"] = "Probability" if density else "Count"
+        df.attrs["title"] = "Histogram"
+
+        return df
 
     def max(self):
         """The maximum value of this stat."""


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e3d20b255..e41e554c0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -135,6 +135,37 @@ def bipartite_graph4():
     return G
 
 
+@pytest.fixture
+def bipartite_digraph1():
+    G = nx.DiGraph()
+    G.add_nodes_from([1, 2, 3, 4], bipartite=0)
+    G.add_nodes_from(["a", "b", "c"], bipartite=1)
+    G.add_edges_from(
+        [
+            ("a", 1),
+            (1, "b"),
+            ("b", 2),
+            (2, "c"),
+            ("c", 3),
+            (4, "a"),
+        ]
+    )
+    return G
+
+
+@pytest.fixture
+def bipartite_digraph2():
+    G = nx.DiGraph()
+    G.add_nodes_from([1], bipartite=0)
+    G.add_nodes_from(["a"], bipartite=1)
+    G.add_edges_from(
+        [
+            ("a", 1, {"direction": "invalid"}),
+        ]
+    )
+    return G
+
+
 @pytest.fixture
 def attr0():
     return {"color": "brown", "name": "camel"}
diff --git a/tests/convert/test_bipartite_graph.py b/tests/convert/test_bipartite_graph.py
index 568bc3206..a716fae3e 100644
--- a/tests/convert/test_bipartite_graph.py
+++ b/tests/convert/test_bipartite_graph.py
@@ -1,10 +1,11 @@
+import networkx as nx
 import pytest
 
 import xgi
 from xgi.exception import XGIError
 
 
-def test_to_bipartite_graph(edgelist1, edgelist3, edgelist4):
+def test_to_bipartite_graph(edgelist1, edgelist3, edgelist4, diedgelist1):
     H1 = xgi.Hypergraph(edgelist1)
     H2 = xgi.Hypergraph(edgelist3)
     H3 = xgi.Hypergraph(edgelist4)
@@ -49,9 +50,26 @@ def test_to_bipartite_graph(edgelist1, edgelist3, edgelist4):
     assert sorted(bi_el3) == sorted(true_bi_el3)
     assert G3.edges() == xgi.to_bipartite_graph(H3, index=False).edges()
 
+    ## Directed
+    H = xgi.DiHypergraph(diedgelist1)
+    G = xgi.to_bipartite_graph(H)
+
+    assert isinstance(G, nx.DiGraph)
+    assert set(G.nodes) == {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
+    assert (0, 8) in G.edges
+    assert (8, 0) not in G.edges
+    assert (9, 6) in G.edges
+    assert (6, 9) not in G.edges
+    assert (9, 5) in G.edges
+    assert (9, 5) in G.edges
+
 
 def test_from_bipartite_graph(
-    bipartite_graph1, bipartite_graph2, bipartite_graph3, bipartite_graph4
+    bipartite_graph1,
+    bipartite_graph2,
+    bipartite_graph3,
+    bipartite_graph4,
+    bipartite_digraph1,
 ):
     H = xgi.from_bipartite_graph(bipartite_graph1)
 
@@ -81,3 +99,15 @@ def test_from_bipartite_graph(
     # not bipartite
     with pytest.raises(XGIError):
         H = xgi.from_bipartite_graph(bipartite_graph4, dual=True)
+
+    ### Directed
+    H = xgi.from_bipartite_graph(bipartite_digraph1)
+
+    assert set(H.nodes) == {1, 2, 3, 4}
+    assert set(H.edges) == {"a", "b", "c"}
+    assert H.edges.head("a") == {1}
+    assert H.edges.tail("a") == {4}
+    assert H.edges.head("b") == {2}
+    assert H.edges.tail("b") == {1}
+    assert H.edges.head("c") == {3}
+    assert H.edges.tail("c") == {2}
diff --git a/tests/convert/test_hif_dict.py b/tests/convert/test_hif_dict.py
new file mode 100644
index 000000000..b2b263daf
--- /dev/null
+++ b/tests/convert/test_hif_dict.py
@@ -0,0 +1,226 @@
+import pytest
+
+import xgi
+
+
+def test_to_hif_dict(
+    edgelist1,
+    hyperwithdupsandattrs,
+    simplicialcomplex1,
+    diedgedict1,
+    dihyperwithattrs,
+):
+    H = xgi.Hypergraph(edgelist1)
+    d = xgi.to_hif_dict(H)
+
+    assert "nodes" not in d
+    assert "edges" not in d
+    assert "incidences" in d
+    assert "network-type" in d
+    assert d["network-type"] == "undirected"
+
+    incidences = [
+        {"edge": 0, "node": 1},
+        {"edge": 0, "node": 2},
+        {"edge": 0, "node": 3},
+        {"edge": 1, "node": 4},
+        {"edge": 2, "node": 5},
+        {"edge": 2, "node": 6},
+        {"edge": 3, "node": 6},
+        {"edge": 3, "node": 7},
+        {"edge": 3, "node": 8},
+    ]
+    assert sorted(d["incidences"], key=lambda x: (x["edge"], x["node"])) == incidences
+
+    # hypergraph with attributes
+    hyperwithdupsandattrs["name"] = "test"
+    d = xgi.to_hif_dict(hyperwithdupsandattrs)
+
+    assert "nodes" in d
+    assert "edges" in d
+    assert "incidences" in d
+    assert "network-type" in d
+    assert d["network-type"] == "undirected"
+    assert "metadata" in d
+    assert d["metadata"] == {"name": "test"}
+
+    nodes = [
+        {"node": 1, "attrs": {"color": "red", "name": "horse"}},
+        {"node": 2, "attrs": {"color": "blue", "name": "pony"}},
+        {"node": 3, "attrs": {"color": "yellow", "name": "zebra"}},
+        {"node": 4, "attrs": {"color": "red", "name": "orangutan", "age": 20}},
+        {"node": 5, "attrs": {"color": "blue", "name": "fish", "age": 2}},
+    ]
+
+    edges = [
+        {"edge": 0, "attrs": {"color": "blue"}},
+        {"edge": 1, "attrs": {"color": "red", "weight": 2}},
+        {"edge": 2, "attrs": {"color": "yellow"}},
+        {"edge": 3, "attrs": {"color": "purple"}},
+        {"edge": 4, "attrs": {"color": "purple", "name": "test"}},
+    ]
+
+    incidences = [
+        {"edge": 0, "node": 1},
+        {"edge": 0, "node": 2},
+        {"edge": 1, "node": 1},
+        {"edge": 1, "node": 2},
+        {"edge": 2, "node": 1},
+        {"edge": 2, "node": 2},
+        {"edge": 3, "node": 3},
+        {"edge": 3, "node": 4},
+        {"edge": 3, "node": 5},
+        {"edge": 4, "node": 3},
+        {"edge": 4, "node": 4},
+        {"edge": 4, "node": 5},
+    ]
+
+    assert sorted(d["nodes"], key=lambda x: x["node"]) == nodes
+    assert d["edges"] == edges
+    assert sorted(d["incidences"], key=lambda x: (x["edge"], x["node"])) == incidences
+
+    # Simplicial complexes
+    d = xgi.to_hif_dict(simplicialcomplex1)
+
+    assert "nodes" not in d
+    assert "edges" not in d
+    assert "incidences" in d
+    assert "network-type" in d
+    assert d["network-type"] == "asc"
+
+    incidences = [
+        {"edge": "e1", "node": 0},
+        {"edge": "e1", "node": "b"},
+        {"edge": "e2", "node": 0},
+        {"edge": "e2", "node": "c"},
+        {"edge": "e3", "node": 0},
+        {"edge": "e3", "node": "b"},
+        {"edge": "e3", "node": "c"},
+        {"edge": "e4", "node": "b"},
+        {"edge": "e4", "node": "c"},
+    ]
+
+    def _mixed(ele):
+        return (0, int(ele)) if isinstance(ele, int) else (1, ele)
+
+    sorted_incidences = sorted(
+        d["incidences"], key=lambda x: (_mixed(x["edge"]), _mixed(x["node"]))
+    )
+    assert sorted_incidences == incidences
+
+    # dihypergraphs without attributes
+    H = xgi.DiHypergraph(diedgedict1)
+
+    d = xgi.to_hif_dict(H)
+
+    assert "nodes" not in d
+    assert "edges" not in d
+    assert "incidences" in d
+
+    # dihypergraphs with attributes
+    d = xgi.to_hif_dict(dihyperwithattrs)
+
+    assert "nodes" in d
+    assert "edges" in d
+    assert "incidences" in d
+
+
+def test_from_hif_dict(
+    hyperwithdupsandattrs,
+    simplicialcomplex1,
+    dihyperwithattrs,
+):
+    d = xgi.to_hif_dict(hyperwithdupsandattrs)
+
+    # test basic import
+    H = xgi.from_hif_dict(d)
+
+    assert isinstance(H, xgi.Hypergraph)
+    assert (H.num_nodes, H.num_edges) == (5, 5)
+    assert set(H.nodes) == {1, 2, 3, 4, 5}
+    assert H.nodes[1] == {"color": "red", "name": "horse"}
+    assert H.nodes[2] == {"color": "blue", "name": "pony"}
+    assert H.nodes[3] == {"color": "yellow", "name": "zebra"}
+    assert H.nodes[4] == {"color": "red", "name": "orangutan", "age": 20}
+    assert H.nodes[5] == {"color": "blue", "name": "fish", "age": 2}
+
+    assert set(H.edges) == {0, 1, 2, 3, 4}
+    assert H.edges[0] == {"color": "blue"}
+    assert H.edges[1] == {"color": "red", "weight": 2}
+    assert H.edges[2] == {"color": "yellow"}
+    assert H.edges[3] == {"color": "purple"}
+    assert H.edges[4] == {"color": "purple", "name": "test"}
+
+    edgedict = {0: {1, 2}, 1: {1, 2}, 2: {1, 2}, 3: {3, 4, 5}, 4: {3, 4, 5}}
+    assert H.edges.members(dtype=dict) == edgedict
+
+    # cast nodes and edges
+    H = xgi.from_hif_dict(d, nodetype=str, edgetype=float)
+    assert set(H.nodes) == {"1", "2", "3", "4", "5"}
+    assert set(H.edges) == {0.0, 1.0, 2.0, 3.0, 4.0}
+
+    assert H.nodes["1"] == {"color": "red", "name": "horse"}
+    assert H.edges[0.0] == {"color": "blue"}
+
+    edgedict = {
+        0: {"1", "2"},
+        1: {"1", "2"},
+        2: {"1", "2"},
+        3: {"3", "4", "5"},
+        4: {"3", "4", "5"},
+    }
+    assert H.edges.members(dtype=dict) == edgedict
+
+    ds = xgi.to_hif_dict(simplicialcomplex1)
+
+    S = xgi.from_hif_dict(ds)
+    assert isinstance(S, xgi.SimplicialComplex)
+    assert (S.num_nodes, S.num_edges) == (3, 4)
+
+    assert set(S.nodes) == {0, "b", "c"}
+    assert set(S.edges) == {"e1", "e2", "e3", "e4"}
+
+    # dihypergraphs
+    d = xgi.to_hif_dict(dihyperwithattrs)
+
+    DH = xgi.from_hif_dict(d)
+    assert (DH.num_nodes, DH.num_edges) == (6, 3)
+    assert isinstance(DH, xgi.DiHypergraph)
+    assert set(DH.nodes) == {0, 1, 2, 3, 4, 5}
+    assert set(DH.edges) == {0, 1, 2}
+
+    edgedict = {0: ({0, 1}, {2}), 1: ({1, 2}, {4}), 2: ({2, 3, 4}, {4, 5})}
+    assert DH.edges.dimembers(dtype=dict) == edgedict
+
+    # test error checking
+    with pytest.raises(TypeError):
+        S = xgi.from_hif_dict(ds, edgetype=int)
+
+    # metadata
+    hyperwithdupsandattrs["name"] = "test"
+    d = xgi.to_hif_dict(hyperwithdupsandattrs)
+    H = xgi.from_hif_dict(d)
+
+    assert H["name"] == "test"
+
+    # test isolates and empty edges
+    H = xgi.Hypergraph()
+    H.add_nodes_from(range(5))
+    H.add_edges_from([[1, 2, 3], []])
+
+    d = xgi.to_hif_dict(H)
+
+    H = xgi.from_hif_dict(d)
+    assert H.edges.size.aslist() == [3, 0]
+    assert set(H.nodes.isolates()) == {0, 4}
+
+    H = xgi.DiHypergraph()
+    H.add_nodes_from(range(5))
+    H.add_edges_from([([1, 2, 3], [2, 4]), [[], []]])
+
+    d = xgi.to_hif_dict(H)
+
+    H = xgi.from_hif_dict(d)
+
+    assert H.edges.size.aslist() == [4, 0]
+    assert set(H.nodes.isolates()) == {0}
diff --git a/tests/drawing/test_draw.py b/tests/drawing/test_draw.py
index 24c301a40..a2f695beb 100644
--- a/tests/drawing/test_draw.py
+++ b/tests/drawing/test_draw.py
@@ -33,7 +33,7 @@ def test_draw(edgelist8):
         assert patch.get_zorder() == z
     assert node_collection.get_zorder() == 4  # nodes
 
-    plt.close()
+    plt.close("all")
 
     # simplicial complex
     S = xgi.SimplicialComplex(edgelist8)
@@ -58,7 +58,7 @@ def test_draw(edgelist8):
     for patch, z in zip(ax.patches, [0, 2, 2]):  # hyperedges
         assert patch.get_zorder() == z
 
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_nodes(edgelist8):
@@ -116,10 +116,10 @@ def test_draw_nodes(edgelist8):
     # negative node_lw or node_size
     with pytest.raises(ValueError):
         ax3, node_collection3 = xgi.draw_nodes(H, node_size=-1)
-        plt.close()
+        plt.close("all")
     with pytest.raises(ValueError):
         ax3, node_collection3 = xgi.draw_nodes(H, node_lw=-1)
-        plt.close()
+        plt.close("all")
 
     plt.close("all")
 
@@ -132,14 +132,14 @@ def test_draw_nodes_fc_cmap(edgelist8):
     fig, ax = plt.subplots()
     ax, node_collection = xgi.draw_nodes(H, ax=ax, node_fc="r")
     assert node_collection.get_cmap() == plt.cm.viridis
-    plt.close()
+    plt.close("all")
 
     # default cmap
     fig, ax = plt.subplots()
     colors = [11, 12, 14, 16, 17, 19, 21]
     ax, node_collection = xgi.draw_nodes(H, ax=ax, node_fc=colors)
     assert node_collection.get_cmap() == plt.cm.Reds
-    plt.close()
+    plt.close("all")
 
     # set cmap
     fig, ax = plt.subplots()
@@ -148,13 +148,13 @@ def test_draw_nodes_fc_cmap(edgelist8):
     )
     assert node_collection.get_cmap() == plt.cm.Greens
     assert (min(colors), max(colors)) == node_collection.get_clim()
-    plt.close()
+    plt.close("all")
 
     # vmin/vmax
     fig, ax = plt.subplots()
     ax, node_collection = xgi.draw_nodes(H, ax=ax, node_fc=colors, vmin=14, vmax=19)
     assert (14, 19) == node_collection.get_clim()
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_nodes_interp(edgelist8):
@@ -168,7 +168,7 @@ def test_draw_nodes_interp(edgelist8):
     ax, node_collection = xgi.draw_nodes(H, ax=ax, node_size=1, node_lw=10)
     assert np.all(node_collection.get_sizes() == np.array([1]))
     assert np.all(node_collection.get_linewidth() == np.array([10]))
-    plt.close()
+    plt.close("all")
 
     # rescaling does not affect scalars
     fig, ax = plt.subplots()
@@ -177,7 +177,7 @@ def test_draw_nodes_interp(edgelist8):
     )
     assert np.all(node_collection.get_sizes() == np.array([1]))
     assert np.all(node_collection.get_linewidth() == np.array([10]))
-    plt.close()
+    plt.close("all")
 
     # not rescaling IDStat
     fig, ax = plt.subplots()
@@ -186,7 +186,7 @@ def test_draw_nodes_interp(edgelist8):
     )
     assert np.all(node_collection.get_sizes() == deg_arr**2)
     assert np.all(node_collection.get_linewidth() == deg_arr)
-    plt.close()
+    plt.close("all")
 
     # rescaling IDStat
     fig, ax = plt.subplots()
@@ -197,7 +197,7 @@ def test_draw_nodes_interp(edgelist8):
     assert max(node_collection.get_sizes()) == 30**2
     assert min(node_collection.get_linewidth()) == 0
     assert max(node_collection.get_linewidth()) == 5
-    plt.close()
+    plt.close("all")
 
     # rescaling IDStat with manual values
     fig, ax = plt.subplots()
@@ -218,7 +218,7 @@ def test_draw_nodes_interp(edgelist8):
     assert max(node_collection.get_sizes()) == 20**2
     assert min(node_collection.get_linewidth()) == 1
     assert max(node_collection.get_linewidth()) == 10
-    plt.close()
+    plt.close("all")
 
     # rescaling ndarray
     fig, ax = plt.subplots()
@@ -229,7 +229,7 @@ def test_draw_nodes_interp(edgelist8):
     assert max(node_collection.get_sizes()) == 30**2
     assert min(node_collection.get_linewidth()) == 0
     assert max(node_collection.get_linewidth()) == 5
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_hyperedges(edgelist8):
@@ -281,7 +281,7 @@ def test_draw_hyperedges(edgelist8):
     with pytest.raises(ValueError):
         ax, collections = xgi.draw_hyperedges(H, ax=ax, dyad_lw=-1)
         (dyad_collection, edge_collection) = collections
-        plt.close()
+        plt.close("all")
 
     plt.close("all")
 
@@ -296,7 +296,7 @@ def test_draw_hyperedges_fc_cmap(edgelist8):
     (dyad_collection, edge_collection) = collections
     assert dyad_collection.get_cmap() == plt.cm.Greys
     assert edge_collection.get_cmap() == sb.color_palette("crest_r", as_cmap=True)
-    plt.close()
+    plt.close("all")
 
     # set cmap
     fig, ax = plt.subplots()
@@ -313,7 +313,7 @@ def test_draw_hyperedges_fc_cmap(edgelist8):
 
     assert (min(dyad_colors), max(dyad_colors)) == dyad_collection.get_clim()
     assert (3, 5) == edge_collection.get_clim()
-    plt.close()
+    plt.close("all")
 
     # vmin/vmax
     fig, ax = plt.subplots()
@@ -332,7 +332,7 @@ def test_draw_hyperedges_fc_cmap(edgelist8):
     assert (14, 19) == edge_collection.get_clim()
     assert (5, 6) == dyad_collection.get_clim()
 
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_hyperedges_ec(edgelist8):
@@ -364,7 +364,7 @@ def test_draw_simplices(edgelist8):
     with pytest.raises(XGIError):
         H = xgi.Hypergraph(edgelist8)
         ax = xgi.draw_simplices(H)
-        plt.close()
+        plt.close("all")
 
     S = xgi.SimplicialComplex(edgelist8)
 
@@ -386,7 +386,7 @@ def test_draw_simplices(edgelist8):
     for patch, z in zip(ax.patches, [0, 2, 2]):  # hyperedges
         assert patch.get_zorder() == z
 
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_hypergraph_hull(edgelist8):
@@ -414,7 +414,7 @@ def test_draw_hypergraph_hull(edgelist8):
         assert patch.get_zorder() == z
     assert node_collection.get_zorder() == 4  # nodes
 
-    plt.close()
+    plt.close("all")
 
 
 def test_draw_multilayer(edgelist8):
@@ -461,7 +461,7 @@ def test_draw_multilayer(edgelist8):
     # node_size
     assert np.all(node_coll.get_sizes() == np.array([5**2]))
 
-    plt.close()
+    plt.close("all")
 
     # max_order parameter
     max_order = 2
@@ -484,7 +484,7 @@ def test_draw_multilayer(edgelist8):
 
     offsets = node_coll2.get_offsets()
     assert offsets.shape[0] == H.num_nodes  # nodes
-    plt.close()
+    plt.close("all")
 
     # conn_lines parameter
     ax3, (node_coll3, edge_coll3) = xgi.draw_multilayer(H, conn_lines=False)
@@ -503,7 +503,7 @@ def test_draw_multilayer(edgelist8):
         + num_dyad_collections
         == len(ax3.collections)
     )
-    plt.close()
+    plt.close("all")
 
     # custom parameters
     pos = xgi.circular_layout(H)
diff --git a/tests/readwrite/test_hif.py b/tests/readwrite/test_hif.py
index 62ae6173b..b12b280b5 100644
--- a/tests/readwrite/test_hif.py
+++ b/tests/readwrite/test_hif.py
@@ -1,12 +1,13 @@
 import json
 import tempfile
+from os.path import join
 
 import pytest
 
 import xgi
 
 
-def test_to_hif(
+def test_write_hif(
     edgelist1,
     hyperwithdupsandattrs,
     simplicialcomplex1,
@@ -153,7 +154,7 @@ def _mixed(ele):
     assert "incidences" in jsondata
 
 
-def test_from_hif(
+def test_read_hif(
     hyperwithdupsandattrs,
     simplicialcomplex1,
     dihyperwithattrs,
@@ -247,13 +248,57 @@ def test_from_hif(
     assert H.edges.size.aslist() == [3, 0]
     assert set(H.nodes.isolates()) == {0, 4}
 
-    H = xgi.DiHypergraph()
-    H.add_nodes_from(range(5))
-    H.add_edges_from([([1, 2, 3], [2, 4]), [[], []]])
+    DH = xgi.DiHypergraph()
+    DH.add_nodes_from(range(5))
+    DH.add_edges_from([([1, 2, 3], [2, 4]), [[], []]])
 
     _, filename6 = tempfile.mkstemp()
-    xgi.write_hif(H, filename6)
+    xgi.write_hif(DH, filename6)
+
+    DH = xgi.read_hif(filename6)
+
+    assert DH.edges.size.aslist() == [4, 0]
+    assert set(DH.nodes.isolates()) == {0}
+
+
+def test_read_hif_collection():
+    # test isolates and empty edges
+    H = xgi.Hypergraph()
+    H.add_nodes_from(range(5))
+    H.add_edges_from([[1, 2, 3], []])
+
+    _, filename5 = tempfile.mkstemp()
+    xgi.write_hif(H, filename5)
+
+    H = xgi.read_hif(filename5)
+    assert H.edges.size.aslist() == [3, 0]
+    assert set(H.nodes.isolates()) == {0, 4}
+
+    DH = xgi.DiHypergraph()
+    DH.add_nodes_from(range(5))
+    DH.add_edges_from([([1, 2, 3], [2, 4]), [[], []]])
+    # test collections
 
-    H = xgi.read_hif(filename6)
-    # assert H.edges.size.aslist() == [5, 0]
-    # assert set(H.nodes.isolates()) == {0}
+    # test list collection
+    collection = [H, DH]
+    tempdir = tempfile.mkdtemp()
+
+    xgi.write_hif_collection(collection, tempdir, collection_name="test")
+    collection = xgi.read_hif_collection(
+        join(tempdir, "test_collection_information.json")
+    )
+    assert len(collection) == 2
+    assert isinstance(collection, dict)
+    assert sorted(collection) == ["0", "1"]
+
+    # test dict collection
+    collection = {"dataset1": H, "dataset2": DH}
+    tempdir = tempfile.mkdtemp()
+
+    xgi.write_hif_collection(collection, tempdir, collection_name="test")
+    collection = xgi.read_hif_collection(
+        join(tempdir, "test_collection_information.json")
+    )
+    assert len(collection) == 2
+    assert isinstance(collection, dict)
+    assert sorted(collection) == ["dataset1", "dataset2"]
diff --git a/tests/readwrite/test_xgi_data.py b/tests/readwrite/test_xgi_data.py
index 976c50e3e..ab37a8974 100644
--- a/tests/readwrite/test_xgi_data.py
+++ b/tests/readwrite/test_xgi_data.py
@@ -60,6 +60,11 @@ def test_load_xgi_data(capfd):
     assert collection["as-you-like-it"].num_nodes == 30
     assert collection["as-you-like-it"].num_edges == 80
 
+    # test HIF
+    H = load_xgi_data("recipe-rec")
+    assert H.num_nodes == 9271
+    assert H.num_edges == 77733
+
 
 @pytest.mark.skipif(
     sys.version_info != (3, 12) and not platform.system() == "Linux",
diff --git a/tests/stats/test_core_stats_functions.py b/tests/stats/test_core_stats_functions.py
index a5581f778..4db1132da 100644
--- a/tests/stats/test_core_stats_functions.py
+++ b/tests/stats/test_core_stats_functions.py
@@ -575,6 +575,64 @@ def test_issue_468():
     assert H.edges.size.ashist().equals(df)
 
 
+def test_ashist_attrs_exist():
+    """Test that ashist returns DataFrame with expected attributes."""
+    H = xgi.sunflower(3, 1, 20)
+    df = H.edges.size.ashist()
+
+    # Check that all expected attributes exist
+    assert "xlabel" in df.attrs
+    assert "ylabel" in df.attrs
+    assert "title" in df.attrs
+
+
+def test_ashist_density_labels():
+    """Test that ylabel changes based on density parameter."""
+    H = xgi.sunflower(3, 1, 20)
+
+    # Test default (density=False)
+    df_count = H.edges.size.ashist(density=False)
+    assert df_count.attrs["ylabel"] == "Count"
+
+    # Test with density=True
+    df_density = H.edges.size.ashist(density=True)
+    assert df_density.attrs["ylabel"] == "Probability"
+
+
+def test_ashist_original_functionality():
+    """Test that adding attributes doesn't break original functionality."""
+    H = xgi.sunflower(3, 1, 20)
+    df = H.edges.size.ashist()
+
+    # Original test case should still pass
+    expected_df = pd.DataFrame([[20.0, 3]], columns=["bin_center", "value"])
+    assert df.equals(expected_df)  # Original functionality
+
+    # And should have attributes
+    assert "xlabel" in df.attrs
+
+
+def test_ashist_single_unique_value():
+    """Test ashist when there is only one unique value and multiple bins."""
+    H = xgi.Hypergraph()
+    H.add_nodes_from(range(5))
+    # All edges have the same size
+    H.add_edges_from([[0, 1], [2, 3], [4, 0]])
+
+    # The edge sizes will all be 2
+    df = H.edges.size.ashist(bins=10)
+
+    # Since there's only one unique value, bins should be set to 1
+    assert len(df) == 1  # Only one bin should be present
+    assert df["bin_center"].iloc[0] == 2  # The bin center should be the unique value
+    assert df["value"].iloc[0] == 3  # There are three edges of size 2
+
+    # Check that attributes are present
+    assert "xlabel" in df.attrs
+    assert "ylabel" in df.attrs
+    assert "title" in df.attrs
+
+
 ### Attribute statistics