Repository: chemosim-lab/ProLIF

Issue:
Count fingerprint
- [x] Switch builtin interaction classes to `yield` instead of `return`
- [x] Add `count: bool` attribute to `Fingerprint`
- [x] Switch metadata to `Tuple[Dict]` instead of `Dict`
- [x] Update `to_dataframe`
- [x] Update tests
- [x] Update `to_bitvector`
- [x] Update tests
- [x] Update LigNetwork to use tuple of metadata
- [x] Update/add test
- [x] Update docs: custom interaction

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) Report
> Merging [#129](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) (e9c6cc1) into [master](https://app.codecov.io/gh/chemosim-lab/ProLIF/commit/7c1c28483ec228af9d96c778a14a6914ffa0b875?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) (7c1c284) will **increase** coverage by `0.19%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master     #129      +/-   ##
==========================================
+ Coverage   96.40%   96.60%   +0.19%     
==========================================
  Files          14       14              
  Lines        1114     1148      +34     
==========================================
+ Hits         1074     1109      +35     
+ Misses         40       39       -1     
```


| [Impacted Files](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) | Coverage Δ | |
|---|---|---|
| [prolif/ifp.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2lmcC5weQ==) | `100.00% <ø> (ø)` | |
| [prolif/interactions/utils.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ludGVyYWN0aW9ucy91dGlscy5weQ==) | `73.33% <ø> (ø)` | |
| [prolif/fingerprint.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ZpbmdlcnByaW50LnB5) | `97.50% <100.00%> (+0.99%)` | :arrow_up: |
| [prolif/interactions/base.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ludGVyYWN0aW9ucy9iYXNlLnB5) | `98.84% <100.00%> (+<0.01%)` | :arrow_up: |
| [prolif/interactions/interactions.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ludGVyYWN0aW9ucy9pbnRlcmFjdGlvbnMucHk=) | `100.00% <100.00%> (ø)` | |
| [prolif/plotting/network.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL3Bsb3R0aW5nL25ldHdvcmsucHk=) | `96.35% <100.00%> (+0.15%)` | :arrow_up: |
| [prolif/utils.py](https://app.codecov.io/gh/chemosim-lab/ProLIF/pull/129?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL3V0aWxzLnB5) | `99.13% <100.00%> (+0.03%)` | :arrow_up: |



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b71728..a7b1ef4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 
 ### Added
+- Added a `count` argument in `Fingerprint`. If `count=True`, enumerates all groups of
+  atoms that satisfy interaction constraints (instead of stopping at the first one),
+  allowing users to generate a count-fingerprint. The `Fingerprint.to_dataframe` method
+  has been modified accordingly, and a `Fingerprint.to_countvectors` method has been
+  added to generate a list of RDKit's `UIntSparseIntVect` from the count-fingerprint.
+  The visualisation scripts have been updated to display the occurence with the shortest
+  distance when a count-fingerprint is being used.
 - Added a `parameters` argument in `Fingerprint` to easily update the parameters used
   by an interaction, instead of defining a new interaction class (Issue #118).
 - Added new abstract interaction classes `SingleAngle` and `DoubleAngle` to more easily
diff --git a/docs/notebooks/how-to.ipynb b/docs/notebooks/how-to.ipynb
index bca8916..66e6449 100644
--- a/docs/notebooks/how-to.ipynb
+++ b/docs/notebooks/how-to.ipynb
@@ -1,6 +1,7 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -43,6 +44,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -50,6 +52,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -70,9 +73,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "In this example, we'll redefine the hydrophobic interaction with a shorter distance. To know which parameters are available for interactions, see the [prolif.interactions](../source/modules/interaction-fingerprint.html#detecting-interactions-between-residues-prolif-interactions) module.\n",
+    "In this example, we'll reparametrize the hydrophobic interaction with a shorter distance. To know which parameters are available for interactions, see the [prolif.interactions](../source/modules/interaction-fingerprint.html#detecting-interactions-between-residues-prolif-interactions-interactions) module.\n",
     "\n",
-    "Let's start with a test case: with the default parameters, Y109 is interacting with our ligand."
+    "Let's start with a test case: with the default parameters, Y109 is interacting with our ligand in 4 different occasions."
    ]
   },
   {
@@ -82,7 +85,8 @@
    "outputs": [],
    "source": [
     "fp = plf.Fingerprint()\n",
-    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
+    "hydrophobic_interactions = fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])\n",
+    "len(list(hydrophobic_interactions))"
    ]
   },
   {
@@ -100,10 +104,12 @@
    "outputs": [],
    "source": [
     "fp = plf.Fingerprint(parameters={\"Hydrophobic\": {\"distance\": 4.0}})\n",
-    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
+    "hydrophobic_interactions = fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])\n",
+    "len(list(hydrophobic_interactions))"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -115,7 +121,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "### Reparameterizing an interaction for a different purpose"
+    "### Repurposing an existing interaction"
    ]
   },
   {
@@ -139,12 +145,16 @@
     "\n",
     "\n",
     "fp = plf.Fingerprint([\"Hydrophobic\", \"ShorterHydrophobic\"])\n",
-    "hydrophobic = fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])\n",
-    "shorterhydrophobic = fp.shorterhydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])\n",
-    "print(f\"{hydrophobic=}, {shorterhydrophobic=}\")"
+    "hydrophobic_interactions = fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])\n",
+    "print(len(list(hydrophobic_interactions)))  # 4\n",
+    "shorterhydrophobic_interactions = fp.shorterhydrophobic(\n",
+    "    lmol[\"LIG1.G\"], pmol[\"TYR109.A\"]\n",
+    ")\n",
+    "print(len(list(shorterhydrophobic_interactions)))  # 0"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -152,12 +162,14 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Before you dive into this section, make sure that there isn't already an interaction that could just be **reparameterized** to do what you want!\n",
+    "Before you dive into this section, make sure that there isn't already an interaction that could just be **repurposed** to do what you want!\n",
     "\n",
-    "For this, the best is to check the section of the documentation corresponding to the `prolif.interactions` module. There are some generic interactions, like the `_Distance` class, if you just need to define two chemical moieties within a certain distance. Both the Hydrophobic, Ionic, and Metallic interactions inherit from this class!"
+    "For this, the best is to check the section of the documentation corresponding to the `prolif.interactions` module. There are some generic interactions, like the `Distance` class, if you just need to define two chemical moieties within a certain distance. Both the Hydrophobic, Ionic, and Metallic interactions inherit from this class!\n",
+    "In most cases, defining an interaction only based on a distance is not enough and requires one or two angles constraints as well. For this purpose, the `SingleAngle` and `DoubleAngle` interactions can be used."
    ]
   },
   {
@@ -173,7 +185,7 @@
     "\n",
     "* **Naming convention**\n",
     "\n",
-    "Your class name must not start with `_` or be named `Interaction`. For non-symmetrical interactions, like hydrogen bonds or salt-bridges, the convention used here is to name the class after the role of the ligand. For example, the class `HBDonor` detects if a ligand acts as a hydrogen bond donor, and the class `Cationic` detects if a ligand acts as a cation.\n",
+    "For non-symmetrical interactions, like hydrogen bonds or salt-bridges, the convention used here is to name the class after the role of the ligand. For example, the class `HBDonor` detects if a ligand acts as a hydrogen bond donor, and the class `Cationic` detects if a ligand acts as a cation.\n",
     "\n",
     "* **Define a `detect` method**\n",
     "\n",
@@ -181,13 +193,13 @@
     "\n",
     "* **Return value for the `detect` method**\n",
     "\n",
-    "You must return a dictionary containing some basic metadata about the interaction when it is detected:\n",
+    "You must yield a dictionary containing some basic metadata about the interaction when it is detected. To help with this process, the `metadata` method should be used (see example below) for which the arguments are listed here:\n",
     "\n",
-    "  * the input residues, named `lig_res` and `prot_res`,\n",
-    "  * the indices of atoms responsible for the interaction, named `lig_indices` and `prot_indices`,\n",
-    "  * any other metric (distances or angles), named as you want.\n",
+    "  * the input residues (`lig_res` and `prot_res` arguments, of type `rdkit.Chem.Mol`),\n",
+    "  * the indices of atoms responsible for the interaction, (`lig_indices` and `prot_indices` arguments, of type `tuple[int, ...]`),\n",
+    "  * any other relevant metric (distances or angles), named as you want. Distances should be in angstroms, and preferably named `distance`, and angles should be in degrees.\n",
     "  \n",
-    "To help with this process, the `metadata` method should be used (see example below).\n",
+    "\n",
     "Note that you don't have to return anything if no interaction is detected for a pair of residues."
    ]
   },
@@ -197,6 +209,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
+    "import numpy as np\n",
     "from scipy.spatial import distance_matrix\n",
     "\n",
     "\n",
@@ -207,28 +220,26 @@
     "    def detect(self, ligand_residue, protein_residue):\n",
     "        # distance matrix between atoms of both residues\n",
     "        dist_matrix = distance_matrix(ligand_residue.xyz, protein_residue.xyz)\n",
-    "        if (dist_matrix <= self.contact_threshold).any():\n",
-    "            # indices of atoms with the shortest distance\n",
-    "            ligand_index, protein_index = divmod(\n",
-    "                int(dist_matrix.argmin()), dist_matrix.shape[1]\n",
-    "            )\n",
-    "            # shortest distance value\n",
-    "            min_dist = dist_matrix[ligand_index, protein_index]\n",
-    "            # return dict with metadata on the interaction\n",
-    "            # required arguments: input residues, and tuple of indices of atoms\n",
-    "            #                     responsible for the interaction\n",
-    "            # optional arguments: any additional `key=value` pair (e.g. distance)\n",
-    "            return self.metadata(\n",
-    "                lig_res=ligand_residue,\n",
-    "                prot_res=protein_residue,\n",
-    "                lig_indices=(ligand_index,),\n",
-    "                prot_indices=(protein_index,),\n",
-    "                distance=min_dist,\n",
-    "            )\n",
+    "        below_threshold = dist_matrix < self.contact_threshold\n",
+    "        for ligand_indices in np.argwhere(below_threshold.any(axis=1)):\n",
+    "            ligand_index = int(ligand_indices[0])\n",
+    "            for protein_indices in np.argwhere(below_threshold[ligand_index]):\n",
+    "                protein_index = int(protein_indices[0])\n",
+    "                # yield dict with metadata on the interaction\n",
+    "                # required arguments: input residues, and tuple of indices of atoms\n",
+    "                #                     responsible for the interaction\n",
+    "                # optional arguments: any additional `key=value` pair (e.g. distance)\n",
+    "                yield self.metadata(\n",
+    "                    lig_res=ligand_residue,\n",
+    "                    prot_res=protein_residue,\n",
+    "                    lig_indices=(ligand_index,),\n",
+    "                    prot_indices=(protein_index,),\n",
+    "                    distance=dist_matrix[ligand_index, protein_index],\n",
+    "                )\n",
     "\n",
     "\n",
     "fp = plf.Fingerprint([\"CloseContact\"])\n",
-    "fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"])"
+    "len(list(fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"])))"
    ]
   },
   {
@@ -293,7 +304,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"], metadata=True)"
+    "list(fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"], metadata=True))"
    ]
   },
   {
@@ -314,6 +325,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -341,6 +353,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -364,6 +377,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -400,6 +414,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -421,6 +436,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -428,6 +444,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -453,6 +470,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -474,6 +492,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -498,6 +517,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -521,6 +541,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -539,6 +560,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -568,6 +590,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
diff --git a/docs/notebooks/quickstart.ipynb b/docs/notebooks/quickstart.ipynb
index a738aa3..b78e4b2 100644
--- a/docs/notebooks/quickstart.ipynb
+++ b/docs/notebooks/quickstart.ipynb
@@ -1,6 +1,7 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -29,6 +30,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -59,6 +61,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -89,6 +92,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -112,10 +116,12 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Everything looks good, we can now compute a fingerprint:"
+    "Everything looks good, we can now generate a fingerprint. By default, ProLIF will calculate the following interactions: Hydrophobic, HBDonor, HBAcceptor, PiStacking, Anionic, Cationic, CationPi, PiCation, VdWContact.\n",
+    "You can list all interactions that are available with the following command:"
    ]
   },
   {
@@ -124,13 +130,31 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# use default interactions\n",
+    "plf.Fingerprint.list_available()"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default fingerprint will only keep track of the first group of atoms that satisfied the constraints per interaction type and residue pair. If you want to keep track of all possible interactions to generate a count-fingerprint (e.g. \"there are two atoms in the ligand that make an HBond-donor interaction with residue X\"), use `plf.Fingerprint(count=True)`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# use defaults\n",
     "fp = plf.Fingerprint()\n",
     "# run on a slice of frames from begining to end with a step of 10\n",
     "fp.run(u.trajectory[::10], lig, prot)"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -156,6 +180,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -196,6 +221,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -277,6 +303,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -297,6 +324,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -333,7 +361,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.13"
+   "version": "3.9.13"
   }
  },
  "nbformat": 4,
diff --git a/docs/notebooks/visualisation.ipynb b/docs/notebooks/visualisation.ipynb
index f5dede8..94521f6 100644
--- a/docs/notebooks/visualisation.ipynb
+++ b/docs/notebooks/visualisation.ipynb
@@ -1,12 +1,16 @@
 {
  "cells": [
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
     "# Visualisation\n",
     "\n",
-    "This notebook showcases different ways of visualizing lig-prot and prot-prot interactions, either with atomistic details or simply at the residue level."
+    "This notebook showcases different ways of visualizing ligand-protein and protein-protein interactions, either with atomistic details or simply at the residue level.\n",
+    "\n",
+    "By default, ProLIF will only retain the first group of atoms that satisfy the constraints (SMARTS patterns and distance, angles...etc) for a given interaction and pair of residues. This means that the some of the visualization features may highlight atoms that are not the \"optimal\" one.\n",
+    "To mitigate this, we can use a count-fingerprint (`prolif.Fingerprint(count=True)`) to keep track of all occurences of an interaction between a pair of residues, and then restrict ourselves to only display the one with the shortest distance. The downside is that this count-fingerprint can be slightly slower to generate."
    ]
   },
   {
@@ -44,8 +48,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# get lig-prot interactions\n",
-    "fp = plf.Fingerprint()\n",
+    "# get lig-prot interactions on the first frame\n",
+    "fp = plf.Fingerprint(count=True)\n",
     "fp.run(u.trajectory[0:1], lig, prot)"
    ]
   },
@@ -73,6 +77,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -99,6 +104,7 @@
     "    \"Anionic\": \"blue\",\n",
     "    \"PiStacking\": \"purple\",\n",
     "}\n",
+    "frame_number = 0\n",
     "\n",
     "# JavaScript functions\n",
     "resid_hover = \"\"\"function(atom,viewer) {{\n",
@@ -122,12 +128,13 @@
     "    }\n",
     "}\"\"\"\n",
     "\n",
+    "ifp = fp.ifp[frame_number]\n",
+    "\n",
     "v = py3Dmol.view(650, 600)\n",
     "v.removeAllModels()\n",
     "\n",
     "models = {}\n",
     "mid = -1\n",
-    "ifp = next(iter(fp.ifp.values()))\n",
     "for (lresid, presid), interactions in ifp.items():\n",
     "    lres = lmol[lresid]\n",
     "    pres = pmol[presid]\n",
@@ -144,7 +151,9 @@
     "            # add residue label\n",
     "            model.setHoverable({}, True, resid_hover.format(resid), unhover_func)\n",
     "            models[resid] = mid\n",
-    "    for interaction, metadata in interactions.items():\n",
+    "    for interaction, metadata_tuple in interactions.items():\n",
+    "        # get interacting atoms with the shortest distance\n",
+    "        metadata = min(metadata_tuple, key=lambda m: m.get(\"distance\", float(\"nan\")))\n",
     "        # get coordinates for both points of the interaction\n",
     "        if interaction in [\"PiStacking\", \"EdgeToFace\", \"FaceToFace\", \"PiCation\"]:\n",
     "            p1 = get_ring_centroid(lres, metadata[\"indices\"][\"ligand\"])\n",
@@ -200,6 +209,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -221,11 +231,11 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp = plf.Fingerprint()\n",
+    "fp = plf.Fingerprint(count=True)\n",
     "fp.run(u.trajectory[::10], lig, prot)\n",
     "net = fp.to_ligplot(\n",
     "    lmol,\n",
-    "    # replace with `kind=\"frame\", frame=0` for the other depiction\n",
+    "    # replace with `kind=\"frame\", frame=0,` for the other depiction\n",
     "    kind=\"aggregate\",\n",
     "    threshold=0.3,\n",
     "    rotation=270,\n",
@@ -234,6 +244,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -243,6 +254,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -366,6 +378,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -392,6 +405,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -418,6 +432,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -473,6 +488,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -505,6 +521,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -571,6 +588,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -578,6 +596,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
diff --git a/prolif/fingerprint.py b/prolif/fingerprint.py
index e5b67f3..5f5c6e2 100644
--- a/prolif/fingerprint.py
+++ b/prolif/fingerprint.py
@@ -25,6 +25,7 @@ Calculate a Protein-Ligand Interaction Fingerprint --- :mod:`prolif.fingerprint`
 
 """
 from collections.abc import Sized
+from functools import wraps
 
 import dill
 import multiprocess as mp
@@ -36,7 +37,28 @@ from prolif.ifp import IFP
 from prolif.interactions.base import _BASE_INTERACTIONS, _INTERACTIONS
 from prolif.molecule import Molecule
 from prolif.parallel import MolIterablePool, TrajectoryPool
-from prolif.utils import get_residues_near_ligand, to_bitvectors, to_dataframe
+from prolif.utils import (
+    get_residues_near_ligand,
+    to_bitvectors,
+    to_countvectors,
+    to_dataframe,
+)
+
+
+def first_occurence(interaction):
+    @wraps(interaction)
+    def wrapped(*args, **kwargs):
+        return next(interaction(*args, **kwargs), None)
+
+    return wrapped
+
+
+def all_occurences(interaction):
+    @wraps(interaction)
+    def wrapped(*args, **kwargs):
+        return tuple(interaction(*args, **kwargs))
+
+    return wrapped
 
 
 class Fingerprint:
@@ -54,6 +76,14 @@ class Fingerprint:
     parameters : dict, optional
         New parameters for the interactions. Mapping between an interaction name and a
         dict of parameters as they appear in the interaction class.
+    count : bool
+        For a given interaction class and pair of residues, there might be multiple
+        combinations of atoms that satisfy the interaction constraints. This parameter
+        controls how the fingerprint treats these different combinations:
+
+        * ``False``: returns the first combination that satisfy the constraints (fast)
+        * ``True``: returns all the combinations (slow)
+
     vicinity_cutoff : float
         Automatically restrict the analysis to residues within this range of the ligand.
         This parameter is ignored if the ``residues`` parameter of the ``run`` methods
@@ -68,6 +98,8 @@ class Fingerprint:
         Number of interaction functions registered by the fingerprint
     vicinity_cutoff : float
         Used when calling :func:`prolif.utils.get_residues_near_ligand`.
+    count : bool
+        Whether to keep track of all interaction occurences or just the first one.
     ifp : dict, optional
         Dict of interaction fingerprints in a sparse format for the given trajectory or
         docking poses: ``{<frame number>: <IFP>}``. See the :class:`~prolif.ifp.IFP`
@@ -100,24 +132,24 @@ class Fingerprint:
         u.trajectory[0]  # use coordinates of the first frame
         prot = prolif.Molecule.from_mda(prot)
         lig = prolif.Molecule.from_mda(lig)
-        ifp = fp.generate(lig, prot)
-        prolif.to_dataframe({0: ifp}, fp.interactions.keys())
+        ifp = fp.generate(lig, prot, metadata=True)
+        prolif.to_dataframe({0: ifp}, fp.interactions)
 
     - On a specific pair of residues for a specific interaction:
 
     .. ipython:: python
 
         # ligand-protein
-        fp.hbdonor(lig, prot["ASP129.A"])
+        next(fp.hbdonor(lig, prot["ASP129.A"]))
         # protein-protein
-        fp.hbacceptor(prot["ASP129.A"], prot["CYS133.A"])
+        next(fp.hbacceptor(prot["ASP129.A"], prot["CYS133.A"]))
 
     You can also obtain the indices of atoms responsible for the interaction:
 
     .. ipython:: python
 
         fp.metadata(lig, prot["ASP129.A"])
-        fp.hbdonor(lig, prot["ASP129.A"], metadata=True)
+        next(fp.hbdonor(lig, prot["ASP129.A"], metadata=True), None)
 
 
     .. versionchanged:: 1.0.0
@@ -140,6 +172,8 @@ class Fingerprint:
         instead.
         Added the ``parameters`` argument to set the interaction classes parameters
         without having to create a new class.
+        Added the ``count`` argument to control for a given pair of residues whether to
+        return all occurences of an interaction or only the first one.
 
     """
 
@@ -157,8 +191,10 @@ class Fingerprint:
             "VdWContact",
         ],
         parameters=None,
+        count=False,
         vicinity_cutoff=6.0,
     ):
+        self.count = count
         self._set_interactions(interactions, parameters)
         self.vicinity_cutoff = vicinity_cutoff
 
@@ -172,14 +208,13 @@ class Fingerprint:
         self._check_valid_interactions(parameters, "parameters")
         # add interaction methods
         self.interactions = {}
+        wrapper = all_occurences if self.count else first_occurence
         for name, interaction_cls in _INTERACTIONS.items():
-            if name.startswith("_") or name == "Interaction":
-                continue
             # create instance with custom parameters if available
             interaction = interaction_cls(**parameters.get(name, {}))
             setattr(self, name.lower(), interaction)
             if name in interactions:
-                self.interactions[name] = interaction
+                self.interactions[name] = wrapper(interaction)
 
     def _check_valid_interactions(self, interactions_iterable, varname):
         """Raises a NameError if an unknown interaction is given."""
@@ -228,11 +263,14 @@ class Fingerprint:
         Returns
         -------
         bitvector : numpy.ndarray
-            An array storing the encoded interactions between res1 and res2
+            An array storing the encoded interactions between res1 and res2. Depending
+            on :attr:`Fingerprint.count`, the dtype of the array will be bool or uint8.
         """
         bitvector = [
             interaction(res1, res2) for interaction in self.interactions.values()
         ]
+        if self.count:
+            return np.array([sum(bits) for bits in bitvector], dtype=np.uint8)
         return np.array(bitvector, dtype=bool)
 
     def metadata(self, res1, res2):
@@ -247,9 +285,10 @@ class Fingerprint:
 
         Returns
         -------
-        metadata : dict
-            Metadata dictionnary indexed by interaction name. If a specific interaction
-            is not present between residues, it is filtered out of the dictionary.
+        metadata : dict[str, tuple[dict, ...]]
+            Dict containing tuples of metadata dictionaries indexed by interaction
+            name. If a specific interaction is not present between residues, it is
+            filtered out of the dictionary.
 
 
         .. versionchanged:: 0.3.2
@@ -261,12 +300,11 @@ class Fingerprint:
             rather than just atom indices.
 
         """
-        metadata = {
-            name: metadata
+        return {
+            name: metadata if self.count else (metadata,)
             for name, interaction in self.interactions.items()
-            if (metadata := interaction(res1, res2, metadata=True)) is not None
+            if (metadata := interaction(res1, res2, metadata=True))
         }
-        return metadata
 
     def generate(self, lig, prot, residues=None, metadata=False):
         """Generates the interaction fingerprint between 2 molecules
@@ -295,8 +333,9 @@ class Fingerprint:
             A dictionary indexed by ``(ligand, protein)`` residue pairs. The
             format for values will depend on ``metadata``:
 
-            - A single bitvector if ``metadata=False``
-            - A sparse dictionary of metadata for each interaction if ``metadata=True``
+            - A single numpy array if ``metadata=False``
+            - A sparse dictionary of metadata tuples indexed by interaction name if
+              ``metadata=True``
 
         Example
         -------
@@ -312,8 +351,8 @@ class Fingerprint:
 
         .. versionchanged:: 2.0.0
             ``return_atoms`` replaced by ``metadata``, and it now returns a sparse
-            dictionary of metadata indexed by interaction name instead of a tuple of
-            arrays.
+            dictionary of metadata tuples indexed by interaction name instead of a
+            tuple of arrays.
         """
         ifp = IFP()
         prot_residues = prot.residues if residues == "all" else residues
@@ -589,16 +628,20 @@ class Fingerprint:
         self.ifp = ifp
         return self
 
-    def to_dataframe(self, **kwargs):
+    def to_dataframe(self, count=None, dtype=None, drop_empty=True, index_col="Frame"):
         """Converts fingerprints to a pandas DataFrame
 
         Parameters
         ----------
+        count : bool or None
+            Whether to output a count fingerprint or not.
         dtype : object or None
-            Cast the input of each bit in the bitvector to this type. If None,
-            keep the data as is
+            Cast the dataframe values to this type. If ``None``, uses ``np.uint8`` if
+            ``count=True``, else ``bool``.
         drop_empty : bool
             Drop columns with only empty values
+        index_col : str
+            Name of the index column in the DataFrame
 
         Returns
         -------
@@ -627,10 +670,18 @@ class Fingerprint:
 
         .. versionchanged:: 2.0.0
             Removed the ``return_atoms`` parameter. You can access more metadata
-            information directly through :attr:`~Fingerprint.ifp`.
+            information directly through :attr:`~Fingerprint.ifp`. Added the ``count``
+            parameter.
         """
         if hasattr(self, "ifp"):
-            return to_dataframe(self.ifp, self.interactions.keys(), **kwargs)
+            return to_dataframe(
+                self.ifp,
+                self.interactions,
+                count=self.count if count is None else count,
+                dtype=dtype,
+                drop_empty=drop_empty,
+                index_col=index_col,
+            )
         raise AttributeError("Please use the `run` method before")
 
     def to_bitvectors(self):
@@ -662,6 +713,37 @@ class Fingerprint:
             return to_bitvectors(df)
         raise AttributeError("Please use the `run` method before")
 
+    def to_countvectors(self):
+        """Converts fingerprints to a list of RDKit UIntSparseIntVect
+
+        Returns
+        -------
+        cvs : list
+            A list of :class:`~rdkit.DataStructs.cDataStructs.UIntSparseIntVect`
+            for each frame
+
+        Raises
+        ------
+        AttributeError
+            If the :meth:`run` method hasn't been used
+
+        Example
+        -------
+        ::
+
+            >>> from rdkit.DataStructs import TanimotoSimilarity
+            >>> cv = fp.to_countvectors()
+            >>> TanimotoSimilarity(cv[0], cv[1])
+            0.42
+
+
+        .. versionadded: 2.0.0
+        """
+        if hasattr(self, "ifp"):
+            df = self.to_dataframe()
+            return to_countvectors(df)
+        raise AttributeError("Please use the `run` method before")
+
     def to_pickle(self, path=None):
         """Dumps the fingerprint object as a pickle.
 
diff --git a/prolif/ifp.py b/prolif/ifp.py
index 9f32793..7935350 100644
--- a/prolif/ifp.py
+++ b/prolif/ifp.py
@@ -20,7 +20,7 @@ class IFP(UserDict):
 
         {
             tuple[<residue_id>, <residue_id>]: {
-                <interaction name>: {
+                <interaction name>: tuple[{
                     "indices": {
                         "ligand": tuple[int, ...],
                         "protein": tuple[int, ...]
@@ -30,15 +30,15 @@ class IFP(UserDict):
                         "protein": tuple[int, ...]
                     },
                     <other metadata>: <value>
-                }
+                }, ...]
             }
         }
 
     Here ``<residue_id>`` corresponds to a :class:`~prolif.residue.ResidueId` object.
     For convenience, one can directly use strings rather than ``ResidueId`` objects when
     indexing the IFP, e.g. ``ifp[("LIG1.G", "ASP129.A")]``.
-    You can also use a single ``ResidueId`` or string to returned a filtered IFP only
-    containing interactions with the specified residue, e.g. ``ifp["ASP129.A"]``.
+    You can also use a single ``ResidueId`` or string to return a filtered IFP that only
+    contains interactions with the specified residue, e.g. ``ifp["ASP129.A"]``.
     """
 
     def __getitem__(self, key):
diff --git a/prolif/interactions/base.py b/prolif/interactions/base.py
index f838c7f..6bbe237 100644
--- a/prolif/interactions/base.py
+++ b/prolif/interactions/base.py
@@ -45,8 +45,8 @@ class Interaction:
         register[name] = cls
 
     def __call__(self, lig_res, prot_res, metadata=False):
-        int_data = self.detect(lig_res, prot_res)
-        return int_data if metadata else (int_data is not None)
+        for int_data in self.detect(lig_res, prot_res):
+            yield int_data if metadata else True
 
     def __repr__(self):  # pragma: no cover
         cls = self.__class__
@@ -93,18 +93,19 @@ class Interaction:
         return metadata
 
     @classmethod
-    def invert_role(cls, name, doc):
+    def invert_role(cls, name, docstring):
         """Creates a new interaction class where the role of the ligand and protein
         residues have been swapped. Usefull to create e.g. an acceptor class from a
         donor class.
         """
-        parameters_docs = cls.__doc__.split("\n", maxsplit=1)[1]
-        docstring = f"{doc}\n{parameters_docs}"
-        inverted = type(name, (cls,), {"__doc__": docstring})
+        cls_docstring = cls.__doc__ or "\n"
+        parameters_doc = cls_docstring.split("\n", maxsplit=1)[1]
+        __doc__ = f"{docstring}\n{parameters_doc}"
+        inverted = type(name, (cls,), {"__doc__": __doc__})
 
         def detect(self, ligand, residue):
-            metadata = super(inverted, self).detect(residue, ligand)
-            return self._invert_metadata(metadata)
+            for metadata in super(inverted, self).detect(residue, ligand):
+                yield self._invert_metadata(metadata)
 
         inverted.detect = detect
         return inverted
@@ -137,7 +138,7 @@ class Distance(Interaction, is_abstract=True):
                 aprot = Geometry.Point3D(*prot_res.xyz[prot_match[0]])
                 dist = alig.Distance(aprot)
                 if dist <= self.distance:
-                    return self.metadata(
+                    yield self.metadata(
                         lig_res, prot_res, lig_match, prot_match, distance=dist
                     )
 
@@ -200,7 +201,7 @@ class SingleAngle(Interaction, is_abstract=True):
                     p2l1 = p2.DirectionVector(l1)
                     angle = p2p1.AngleTo(p2l1)
                     if angle_between_limits(angle, *self.angle):
-                        return self.metadata(
+                        yield self.metadata(
                             lig_res,
                             prot_res,
                             lig_match,
@@ -229,6 +230,10 @@ class DoubleAngle(Interaction, is_abstract=True):
         Which atoms to use for the distance calculation: L1 or L2, and P1 or P2
     metadata_mapping : dict, optional
         Mapping for names used in the metadata dict for the distance and angle variables
+
+
+    .. versionadded:: 2.0.0
+
     """
 
     def __init__(
@@ -268,7 +273,7 @@ class DoubleAngle(Interaction, is_abstract=True):
                         l1l2 = l1.DirectionVector(l2)
                         l2l1p2 = l1p2.AngleTo(l1l2)
                         if angle_between_limits(l2l1p2, *self.L2L1P2_angle):
-                            return self.metadata(
+                            yield self.metadata(
                                 lig_res,
                                 prot_res,
                                 lig_match,
@@ -382,7 +387,7 @@ class BasePiStacking(Interaction, is_abstract=True):
                         res_centroid.Distance(intersect),
                     )
                     if intersect_dist <= self.intersect_radius:
-                        return self.metadata(
+                        yield self.metadata(
                             ligand,
                             residue,
                             lig_match,
@@ -393,7 +398,7 @@ class BasePiStacking(Interaction, is_abstract=True):
                             intersect_distance=intersect_dist,
                         )
                 else:
-                    return self.metadata(
+                    yield self.metadata(
                         ligand,
                         residue,
                         lig_match,
diff --git a/prolif/interactions/interactions.py b/prolif/interactions/interactions.py
index cbfccac..f3f733f 100644
--- a/prolif/interactions/interactions.py
+++ b/prolif/interactions/interactions.py
@@ -262,7 +262,7 @@ class CationPi(Interaction):
                 # centroid-cation
                 angle = normal.AngleTo(centroid_cation)
                 if angle_between_limits(angle, *self.angle, ring=True):
-                    return self.metadata(
+                    yield self.metadata(
                         cation,
                         pi,
                         cation_match,
@@ -392,7 +392,8 @@ class PiStacking(Interaction):
         self.etf = EdgeToFace(**etf_kwargs or {})
 
     def detect(self, ligand, residue):
-        return self.ftf.detect(ligand, residue) or self.etf.detect(ligand, residue)
+        yield from self.ftf.detect(ligand, residue)
+        yield from self.etf.detect(ligand, residue)
 
 
 class MetalDonor(Distance):
@@ -476,6 +477,6 @@ class VdWContact(Interaction):
                 rxyz.GetAtomPosition(ra.GetIdx())
             )
             if dist <= vdw:
-                return self.metadata(
+                yield self.metadata(
                     ligand, residue, (la.GetIdx(),), (ra.GetIdx(),), distance=dist
                 )
diff --git a/prolif/interactions/utils.py b/prolif/interactions/utils.py
index e5e01fd..d19581a 100644
--- a/prolif/interactions/utils.py
+++ b/prolif/interactions/utils.py
@@ -1,3 +1,11 @@
+"""
+Utility functions for interactions --- :mod:`prolif.interactions.utils`
+=======================================================================
+
+This module contains some utilities used by the interaction classes.
+"""
+
+
 def get_mapindex(res, index):
     """Get the index of the atom in the original molecule
 
diff --git a/prolif/plotting/network.py b/prolif/plotting/network.py
index cffd7f8..186f232 100644
--- a/prolif/plotting/network.py
+++ b/prolif/plotting/network.py
@@ -45,7 +45,7 @@ class LigNetwork:
     ----------
     df : pandas.DataFrame
         Dataframe with a 4-level index (ligand, protein, interaction, atoms)
-        and a weight column for values
+        and ``weight`` and ``distance`` columns for values
     lig_mol : rdkit.Chem.rdChem.Mol
         Ligand molecule
     match3D : bool
@@ -324,14 +324,20 @@ class LigNetwork:
                 "ligand": str(lig_resid),
                 "protein": str(prot_resid),
                 "interaction": int_name,
-                "atoms": metadata["indices"]["ligand"],
-                "distance": metadata.get("distance", 0),
+                "metadata_tuple": metadata_tuple,
             }
             for frame, ifp in fp.ifp.items()
             for (lig_resid, prot_resid), int_data in ifp.items()
-            for int_name, metadata in int_data.items()
+            for int_name, metadata_tuple in int_data.items()
         ]
+        # extract interaction with shortest distance
+        for entry in data:
+            metadata_tuple = entry.pop("metadata_tuple")
+            metadata = min(metadata_tuple, key=lambda m: m.get("distance", np.nan))
+            entry["atoms"] = metadata["indices"]["ligand"]
+            entry["distance"] = metadata.get("distance", 0)
         df = pd.DataFrame(data)
+        # add weight for each atoms, and average distance
         df["weight"] = 1
         df = df.groupby(["ligand", "protein", "interaction", "atoms"]).agg(
             weight=("weight", "sum"), distance=("distance", "mean")
@@ -363,12 +369,17 @@ class LigNetwork:
                 "ligand": str(lig_resid),
                 "protein": str(prot_resid),
                 "interaction": int_name,
-                "atoms": metadata["indices"]["ligand"],
-                "distance": metadata.get("distance", 0),
+                "metadata_tuple": metadata_tuple,
             }
             for (lig_resid, prot_resid), int_data in ifp.items()
-            for int_name, metadata in int_data.items()
+            for int_name, metadata_tuple in int_data.items()
         ]
+        # extract interaction with shortest distance
+        for entry in data:
+            metadata_tuple = entry.pop("metadata_tuple")
+            metadata = min(metadata_tuple, key=lambda m: m.get("distance", np.nan))
+            entry["atoms"] = metadata["indices"]["ligand"]
+            entry["distance"] = metadata.get("distance", 0)
         df = pd.DataFrame(data)
         df["weight"] = 1
         df = df.set_index(["ligand", "protein", "interaction", "atoms"]).reindex(
diff --git a/prolif/utils.py b/prolif/utils.py
index 01c0ec3..c838135 100644
--- a/prolif/utils.py
+++ b/prolif/utils.py
@@ -14,7 +14,7 @@ import numpy as np
 import pandas as pd
 from rdkit import rdBase
 from rdkit.Chem import FragmentOnBonds, GetMolFrags, SplitMolByPDBResidues
-from rdkit.DataStructs import ExplicitBitVect
+from rdkit.DataStructs import ExplicitBitVect, UIntSparseIntVect
 from rdkit.Geometry import Point3D
 from scipy.spatial import cKDTree
 
@@ -186,9 +186,10 @@ def is_peptide_bond(bond, resids):
 def to_dataframe(
     ifp,
     interactions,
-    index_col="Frame",
+    count=False,
     dtype=None,
     drop_empty=True,
+    index_col="Frame",
 ):
     """Converts IFPs to a pandas DataFrame
 
@@ -197,17 +198,19 @@ def to_dataframe(
     ifp : dict
         A dict in the format
         ``{<frame number>: {(<residue_id>, <residue_id>): <interactions>}}``.
-        ``<interactions>`` is either a :class:`numpy.ndarray` bitvector, or a dict in
-        the format ``{<interaction name>: <metadata dict>}``.
+        ``<interactions>`` is either a :class:`numpy.ndarray` bitvector, or a tuple of
+        dict in the format ``{<interaction name>: <metadata dict>}``.
     interactions : list
         A list of interactions, in the same order as used to detect the interactions.
-    index_col : str
-        Name of the index column in the DataFrame
+    count : bool
+        Whether to output a count fingerprint or not.
     dtype : object or None
-        Cast the input of each bit in the bitvector to this type. If None, keep
-        the data as is.
+        Cast the dataframe values to this type. If ``None``, uses ``np.uint8`` if
+        ``count=True``, else ``bool``.
     drop_empty : bool
         Drop columns with only empty values
+    index_col : str
+        Name of the index column in the DataFrame
 
     Returns
     -------
@@ -233,20 +236,17 @@ def to_dataframe(
         dataframe conversion code
 
     .. versionchanged:: 2.0.0
-        Removed the ``return_atoms`` parameter.
+        Removed the ``return_atoms`` parameter. Added the ``count`` parameter. Removed
+        support for ``ifp`` containing ``np.ndarray`` bitvectors.
     """
     ifp = deepcopy(ifp)
     n_interactions = len(interactions)
-    empty_value = dtype(False) if dtype else False
-    # check if each interaction value is a list of atom indices or smthg else
-    has_atom_indices = False
-    for frame_ifp in ifp.values():
-        for residue_tuple, value in frame_ifp.items():
-            has_atom_indices = isinstance(value, dict)
-            break
+    if dtype is None:
+        dtype = np.uint8 if count else bool
+    empty_value = dtype(0)
     # create empty array for each residue pair interaction that doesn't exist
     # in a particular frame
-    empty_arr = np.array([empty_value for _ in range(n_interactions)])
+    empty_arr = np.array([empty_value for _ in range(n_interactions)], dtype=dtype)
     # residue pairs
     residue_pairs = sorted(
         set(
@@ -260,23 +260,29 @@ def to_dataframe(
         index.append(i)
         for residue_tuple in residue_pairs:
             try:
-                # np.ndarray or sparse metadata dict
-                bitvector = frame_ifp[residue_tuple]
+                ifp_dict = frame_ifp[residue_tuple]
             except KeyError:
                 data[residue_tuple].append(empty_arr[:])
             else:
-                if has_atom_indices:
+                if count:
                     bitvector = np.array(
-                        [i in bitvector for i in interactions], dtype=bool
+                        [len(ifp_dict.get(i, ())) for i in interactions], dtype=dtype
+                    )
+                else:
+                    bitvector = np.array(
+                        [i in ifp_dict for i in interactions], dtype=bool
                     )
                 data[residue_tuple].append(bitvector)
     index = pd.Series(index, name=index_col)
-    # create dataframes
+    # create dataframe
     if not data:
         warnings.warn("No interaction detected")
         return pd.DataFrame([], index=index)
     values = np.array(
-        [np.hstack([np.ravel(a[i]) for a in data.values()]) for i in range(len(index))]
+        [
+            np.hstack([bitvector_list[frame] for bitvector_list in data.values()])
+            for frame in range(len(index))
+        ]
     )
     columns = pd.MultiIndex.from_tuples(
         [
@@ -287,8 +293,7 @@ def to_dataframe(
         names=["ligand", "protein", "interaction"],
     )
     df = pd.DataFrame(values, columns=columns, index=index)
-    if dtype:
-        df = df.astype(dtype)
+    df = df.astype(dtype)
     if drop_empty:
         mask = (df != empty_value).any(axis=0)
         df = df.loc[:, mask]
@@ -328,3 +333,29 @@ def to_bitvectors(df):
 
     """
     return df.apply(pandas_series_to_bv, axis=1).tolist()
+
+
+def pandas_series_to_countvector(s):
+    size = len(s)
+    cv = UIntSparseIntVect(size)
+    for i in range(size):
+        cv[i] = int(s[i])
+    return cv
+
+
+def to_countvectors(df):
+    """Converts an interaction DataFrame to a list of RDKit UIntSparseIntVect
+
+    Parameters
+    ----------
+    df : pandas.DataFrame
+        A DataFrame where each column corresponds to the count for an interaction
+        between two residues
+
+    Returns
+    -------
+    cv : list
+        A list of :class:`~rdkit.DataStructs.cDataStructs.UIntSparseIntVect`
+        for each frame
+    """
+    return df.apply(pandas_series_to_countvector, axis=1).tolist()


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 2ce9a28..51ac8f7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,7 @@ from MDAnalysis import Universe
 from rdkit import Chem
 
 from prolif.datafiles import TOP, TRAJ, datapath
-from prolif.molecule import Molecule
+from prolif.molecule import Molecule, sdf_supplier
 
 
 def pytest_sessionstart(session):
@@ -56,3 +56,9 @@ def protein_rdkit(protein_ag):
 @pytest.fixture(scope="session")
 def protein_mol(protein_ag):
     return Molecule.from_mda(protein_ag)
+
+
+@pytest.fixture(scope="session")
+def sdf_suppl():
+    path = str(datapath / "vina" / "vina_output.sdf")
+    return sdf_supplier(path)
diff --git a/tests/plotting/test_network.py b/tests/plotting/test_network.py
index 0701bc9..8fbff5c 100644
--- a/tests/plotting/test_network.py
+++ b/tests/plotting/test_network.py
@@ -10,11 +10,22 @@ from prolif.plotting.network import LigNetwork
 
 class TestLigNetwork:
     @pytest.fixture(scope="class")
-    def get_ligplot(self):
+    def simple_fp(self):
+        return plf.Fingerprint(count=False)
+
+    @pytest.fixture(scope="class")
+    def count_fp(self):
+        return plf.Fingerprint(count=True)
+
+    @pytest.fixture(scope="class", params=["simple_fp", "count_fp"])
+    def fp(self, request):
+        return request.getfixturevalue(request.param)
+
+    @pytest.fixture(scope="class")
+    def get_ligplot(self, fp):
         u = mda.Universe(plf.datafiles.TOP, plf.datafiles.TRAJ)
         lig = u.select_atoms("resname LIG")
         prot = u.select_atoms("protein and byres around 6.5 group ligand", ligand=lig)
-        fp = plf.Fingerprint()
         fp.run(u.trajectory[0:2], lig, prot)
         lig = plf.Molecule.from_mda(lig)
         return partial(fp.to_ligplot, lig)
diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py
index 49bb5ff..86069b9 100644
--- a/tests/test_fingerprint.py
+++ b/tests/test_fingerprint.py
@@ -4,7 +4,7 @@ import MDAnalysis as mda
 import numpy as np
 import pytest
 from pandas import DataFrame
-from rdkit.DataStructs import ExplicitBitVect
+from rdkit.DataStructs import ExplicitBitVect, UIntSparseIntVect
 
 from prolif.datafiles import datapath
 from prolif.fingerprint import Fingerprint
@@ -15,14 +15,24 @@ from prolif.residue import ResidueId
 
 class Dummy(Interaction):
     def detect(self, res1, res2):
-        return self.metadata(res1, res2, (2,), (4,), distance=4.2)
+        yield self.metadata(res1, res2, (2,), (4,), distance=4.2)
 
 
-def test_interaction_base():
+def test_interaction_base(sdf_suppl):
     interaction = Dummy()
     _repr = repr(interaction)
     assert _repr.startswith("<") and ".Dummy at " in _repr
     assert callable(interaction)
+    mol = sdf_suppl[0]
+    metadata = next(interaction.detect(mol, mol))
+    assert metadata["indices"] == {"ligand": (2,), "protein": (4,)}
+    assert metadata["parent_indices"] == {"ligand": (2,), "protein": (4,)}
+    # invert
+    inverted = Dummy.invert_role("Dummy", "inverted")
+    inverted_interaction = inverted()
+    metadata = next(inverted_interaction.detect(mol, mol))
+    assert metadata["indices"] == {"ligand": (4,), "protein": (2,)}
+    assert metadata["parent_indices"] == {"ligand": (4,), "protein": (2,)}
 
 
 class TestFingerprint:
@@ -31,6 +41,15 @@ class TestFingerprint:
         yield Fingerprint()
         _INTERACTIONS.pop("Dummy", None)
 
+    @pytest.fixture(scope="class")
+    def fp_count(self):
+        yield Fingerprint(count=True)
+        _INTERACTIONS.pop("Dummy", None)
+
+    @pytest.fixture(scope="class", params=["fp", "fp_count"])
+    def any_fp(self, request):
+        return request.getfixturevalue(request.param)
+
     @pytest.fixture(scope="class")
     def fp_simple(self):
         yield Fingerprint(["Hydrophobic"])
@@ -49,20 +68,27 @@ class TestFingerprint:
     def test_init_all(self):
         fp = Fingerprint("all")
         for name, func in fp.interactions.items():
-            assert getattr(fp, name.lower()) is func
+            assert getattr(fp, name.lower()) is func.__wrapped__
 
     def test_n_interactions(self, fp):
         assert fp.n_interactions == len(fp.interactions)
 
-    def test_bitvector(self, fp, ligand_mol, protein_mol):
-        bv = fp.bitvector(ligand_mol[0], protein_mol["ASP129.A"])
-        assert len(bv) == fp.n_interactions
+    def test_bitvector(self, any_fp, ligand_mol, protein_mol):
+        bv = any_fp.bitvector(ligand_mol[0], protein_mol["ASP129.A"])
+        assert len(bv) == any_fp.n_interactions
+        assert (bv == 0).any()
         assert bv.sum() > 0
+        if any_fp.count:
+            assert (bv > 1).any()
 
-    def test_metadata(self, fp, ligand_mol, protein_mol):
-        metadata = fp.metadata(ligand_mol[0], protein_mol["ASP129.A"])
+    def test_metadata(self, any_fp, ligand_mol, protein_mol):
+        metadata = any_fp.metadata(ligand_mol[0], protein_mol["ASP129.A"])
         assert metadata
-        assert isinstance(metadata["HBDonor"]["indices"]["ligand"], tuple)
+        assert "HBAcceptor" not in metadata
+        assert all(
+            isinstance(cationic["indices"]["ligand"], tuple)
+            for cationic in metadata["Cationic"]
+        )
 
     def test_run_residues(self, fp_simple, u, ligand_ag, protein_ag):
         fp_simple.run(
@@ -105,6 +131,12 @@ class TestFingerprint:
         assert isinstance(bv, np.ndarray)
         assert bv[0] is np.True_
 
+    def test_generate_metadata(self, fp_simple, ligand_mol, protein_mol):
+        ifp = fp_simple.generate(ligand_mol, protein_mol, metadata=True)
+        key = (ResidueId("LIG", 1, "G"), ResidueId("VAL", 201, "A"))
+        int_data = ifp[key]
+        assert "Hydrophobic" in int_data
+
     def test_run(self, fp_simple, u, ligand_ag, protein_ag):
         fp_simple.run(
             u.trajectory[0:1], ligand_ag, protein_ag, residues=None, progress=False
@@ -113,9 +145,12 @@ class TestFingerprint:
         ifp = fp_simple.ifp[0]
         interactions = next(iter(ifp.values()))
         assert isinstance(interactions, dict)
-        metadata = next(iter(interactions.values()))
+        metadata_tuple = next(iter(interactions.values()))
         assert all(
-            [key in metadata for key in ["indices", "parent_indices", "distance"]]
+            [
+                key in metadata_tuple[0]
+                for key in ["indices", "parent_indices", "distance"]
+            ]
         )
 
     def test_run_from_iterable(self, fp_simple, protein_mol):
@@ -144,7 +179,7 @@ class TestFingerprint:
         resids = set([key for d in fp_simple.ifp.values() for key in d.keys()])
         assert df.shape == (3, len(resids))
 
-    def test_to_bv(self, fp_simple, u, ligand_ag, protein_ag):
+    def test_to_bitvector(self, fp_simple, u, ligand_ag, protein_ag):
         with pytest.raises(AttributeError, match="use the `run` method"):
             Fingerprint().to_bitvectors()
         fp_simple.run(
@@ -154,6 +189,17 @@ class TestFingerprint:
         assert isinstance(bvs[0], ExplicitBitVect)
         assert len(bvs) == 3
 
+    def test_to_countvectors(self, fp_count, u, ligand_ag, protein_ag):
+        with pytest.raises(AttributeError, match="use the `run` method"):
+            Fingerprint().to_countvectors()
+        fp_count.run(
+            u.trajectory[:3], ligand_ag, protein_ag, residues=None, progress=False
+        )
+        cvs = fp_count.to_countvectors()
+        assert isinstance(cvs[0], UIntSparseIntVect)
+        assert cvs[0][0] == 4
+        assert len(cvs) == 3
+
     def test_list_avail(self):
         available = Fingerprint.list_available()
         assert "Hydrophobic" in available
diff --git a/tests/test_interactions.py b/tests/test_interactions.py
index 928265d..f7fe57b 100644
--- a/tests/test_interactions.py
+++ b/tests/test_interactions.py
@@ -120,7 +120,7 @@ class TestInteractions:
     )
     def test_interaction(self, fingerprint, func_name, lig_mol, prot_mol, expected):
         interaction = getattr(fingerprint, func_name)
-        assert interaction(lig_mol[0], prot_mol[0]) is expected
+        assert next(interaction(lig_mol[0], prot_mol[0]), False) is expected
 
     def test_warning_supersede(self):
         old = id(_INTERACTIONS["Hydrophobic"])
@@ -169,13 +169,9 @@ class TestInteractions:
         "lig_mol, prot_mol", [("benzene", "cation")], indirect=["lig_mol", "prot_mol"]
     )
     def test_vdwcontact_vdwradii_update(self, lig_mol, prot_mol):
-        class CustomVdW(VdWContact):
-            def __init__(self, tolerance=0, vdwradii={"Na": 0}):
-                super().__init__(tolerance, vdwradii)
-
-        metadata = CustomVdW().detect(lig_mol[0], prot_mol[0])
-        assert metadata is None
-        _INTERACTIONS.pop("CustomVdW")
+        vdw = VdWContact(vdwradii={"Na": 0})
+        metadata = vdw.detect(lig_mol[0], prot_mol[0])
+        assert next(metadata, None) is None
 
     @pytest.mark.parametrize(
         ["interaction_qmol", "smiles", "expected"],
@@ -282,11 +278,14 @@ class TestInteractions:
     )
     def test_pi_stacking(self, benzene, xyz, rotation, pi_type, expected, fingerprint):
         r1, r2 = self.create_rings(benzene, xyz, rotation)
-        assert getattr(fingerprint, pi_type)(r1, r2) is expected
+        evaluate = lambda pistacking_type, r1, r2: next(
+            getattr(fingerprint, pistacking_type)(r1, r2), False
+        )
+        assert evaluate(pi_type, r1, r2) is expected
         if expected is True:
             other = "edgetoface" if pi_type == "facetoface" else "facetoface"
-            assert getattr(fingerprint, other)(r1, r2) is not expected
-            assert getattr(fingerprint, "pistacking")(r1, r2) is expected
+            assert evaluate(other, r1, r2) is not expected
+            assert evaluate("pistacking", r1, r2) is expected
 
     @staticmethod
     def create_rings(benzene, xyz, rotation):
@@ -302,5 +301,5 @@ class TestInteractions:
     def test_edgetoface_phe331(self, ligand_mol, protein_mol):
         fp = Fingerprint()
         lig, phe331 = ligand_mol[0], protein_mol["PHE331.B"]
-        assert fp.edgetoface(lig, phe331) is True
-        assert fp.pistacking(lig, phe331) is True
+        assert next(fp.edgetoface(lig, phe331)) is True
+        assert next(fp.pistacking(lig, phe331)) is True
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c413b92..8f54b5e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -20,37 +20,48 @@ from prolif.utils import (
 
 
 @pytest.fixture
-def ifp_bitvector():
+def ifp_single():
     return {
         0: {
-            ("LIG", "ALA1"): np.array([True, False, False]),
-            ("LIG", "GLU2"): np.array([False, True, False]),
+            ("LIG", "ALA1"): {"A": ({"indices": {"ligand": (0,), "protein": (1,)}},)},
+            ("LIG", "GLU2"): {"B": ({"indices": {"ligand": (1,), "protein": (3,)}},)},
         },
         1: {
-            ("LIG", "ALA1"): np.array([True, True, False]),
-            ("LIG", "ASP3"): np.array([False, True, False]),
+            ("LIG", "ALA1"): {
+                "A": ({"indices": {"ligand": (2,), "protein": (4,)}},),
+                "B": ({"indices": {"ligand": (2,), "protein": (5,)}},),
+            },
+            ("LIG", "ASP3"): {"B": ({"indices": {"ligand": (8,), "protein": (10,)}},)},
         },
     }
 
 
 @pytest.fixture
-def ifp_metadata():
+def ifp_count():
     return {
         0: {
-            ("LIG", "ALA1"): {"A": {"indices": {"ligand": (0,), "protein": (1,)}}},
-            ("LIG", "GLU2"): {"B": {"indices": {"ligand": (1,), "protein": (3,)}}},
+            ("LIG", "ALA1"): {
+                "A": (
+                    {"indices": {"ligand": (0,), "protein": (1,)}},
+                    {"indices": {"ligand": (1,), "protein": (1,)}},
+                )
+            },
+            ("LIG", "GLU2"): {"B": ({"indices": {"ligand": (1,), "protein": (3,)}},)},
         },
         1: {
             ("LIG", "ALA1"): {
-                "A": {"indices": {"ligand": (2,), "protein": (4,)}},
-                "B": {"indices": {"ligand": (2,), "protein": (5,)}},
+                "A": (
+                    {"indices": {"ligand": (2,), "protein": (4,)}},
+                    {"indices": {"ligand": (2,), "protein": (1,)}},
+                ),
+                "B": ({"indices": {"ligand": (2,), "protein": (5,)}},),
             },
-            ("LIG", "ASP3"): {"B": {"indices": {"ligand": (8,), "protein": (10,)}}},
+            ("LIG", "ASP3"): {"B": ({"indices": {"ligand": (8,), "protein": (10,)}},)},
         },
     }
 
 
-@pytest.fixture(params=["ifp_bitvector", "ifp_metadata"])
+@pytest.fixture(params=["ifp_single", "ifp_count"])
 def ifp(request):
     return request.getfixturevalue(request.param)
 
@@ -218,12 +229,20 @@ def test_to_df_drop_empty(ifp):
     assert df.shape == (2, 9)
 
 
-def test_to_df_no_interaction_in_first_frame(ifp_metadata):
-    fp = deepcopy(ifp_metadata)
+def test_to_df_no_interaction_in_first_frame(ifp_single):
+    fp = deepcopy(ifp_single)
     fp[0] = {}
     to_dataframe(fp, ["A", "B", "C"])
 
 
+def test_to_df_count(ifp_count):
+    df = to_dataframe(ifp_count, ["A", "B", "C"], count=True)
+    assert df[df > 1].any().any()
+    value = df[("LIG", "ALA1", "A")][0]
+    assert value.dtype == np.uint8
+    assert value == 2
+
+
 def test_to_df_empty_ifp():
     ifp = {0: {}, 1: {}}
     df = to_dataframe(ifp, ["A"])