Repository: matchms/matchms
Pull request #259 (2021-11-03 16:44:40)

add __hash__ method to Spectrum
Adressing #106 

Added here:
`Spectrum()` class now contains 3 new methods:
- `.metadata_hash()`- computes sha256-based hash from metadata (json)
- `.spectrum_hash()`- computes sha256-based hash from peak mz & intensities (insprired by SPLASH)
- `.__hash__`- combines both other hashes and converts it to integer so that `hash(spectrum)` works.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34347faa..c0093c69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- `Spectrum()` objects now also allows generating hashes, e.g. `hash(spectrum)` [#259](https://github.com/matchms/matchms/pull/259)
+- `Spectrum()` objects can generate `.spectrum_hash()` and `.metadata_hash()` to track changes to peaks or metadata [#259](https://github.com/matchms/matchms/pull/259)
+
 ### Changed
 
 - Code linting triggered by pylint update [#257](https://github.com/matchms/matchms/pull/257)
diff --git a/matchms/Spectrum.py b/matchms/Spectrum.py
index 9c2b3b16..1361d8dc 100644
--- a/matchms/Spectrum.py
+++ b/matchms/Spectrum.py
@@ -1,6 +1,8 @@
 from typing import Optional
 import numpy
 from matplotlib import pyplot
+from .hashing import metadata_hash
+from .hashing import spectrum_hash
 from .Spikes import Spikes
 
 
@@ -85,6 +87,25 @@ class Spectrum:
                 return False
         return True
 
+    def __hash__(self):
+        """Return a integer hash which is computed from both
+        metadata (see .metadata_hash() method) and spectrum peaks
+        (see .spectrum_hash() method)."""
+        combined_hash = self.metadata_hash() + self.spectrum_hash()
+        return int.from_bytes(bytearray(combined_hash, 'utf-8'), 'big')
+
+    def spectrum_hash(self):
+        """Return a (truncated) sha256-based hash which is generated
+        based on the spectrum peaks (mz:intensity pairs).
+        Spectra with same peaks will results in same spectrum_hash."""
+        return spectrum_hash(self.peaks)
+
+    def metadata_hash(self):
+        """Return a (truncated) sha256-based hash which is generated
+        based on the spectrum metadata.
+        Spectra with same metadata results in same metadata_hash."""
+        return metadata_hash(self.metadata)
+
     def clone(self):
         """Return a deepcopy of the spectrum instance."""
         clone = Spectrum(mz=self.peaks.mz,
diff --git a/matchms/hashing.py b/matchms/hashing.py
new file mode 100644
index 00000000..b74a270e
--- /dev/null
+++ b/matchms/hashing.py
@@ -0,0 +1,34 @@
+"""Helper functions related to hashing.
+"""
+import hashlib
+import json
+from .Spikes import Spikes
+
+
+def spectrum_hash(peaks: Spikes, hash_length: int = 20,
+                  mz_precision: int = 5, intensity_precision: int = 2):
+    """Compute hash from mz-intensity pairs of all peaks in spectrum.
+    Method is inspired by SPLASH (doi:10.1038/nbt.3689).
+    """
+    mz_precision_factor = 10 ** mz_precision
+    intensity_precision_factor = 10 ** intensity_precision
+
+    def format_mz(mz):
+        return int(mz * mz_precision_factor)
+
+    def format_intensity(intensity):
+        return int(intensity * intensity_precision_factor)
+
+    peak_list = [(format_mz(peak[0]), format_intensity(peak[1])) for peak in peaks.to_numpy]
+    # Sort by increasing m/z and then by decreasing intensity
+    peak_list.sort(key=lambda x: (x[0], - x[1]))
+
+    encoded = " ".join(":".join(map(str, x)) for x in peak_list).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()[:hash_length]
+
+
+def metadata_hash(metadata: dict, hash_length: int = 20):
+    """Compute hash from metadata dictionary.
+    """
+    encoded = json.dumps(metadata, sort_keys=True).encode()
+    return hashlib.sha256(encoded).hexdigest()[:hash_length]


Test patch:
diff --git a/tests/test_hashing.py b/tests/test_hashing.py
new file mode 100644
index 00000000..1356cce8
--- /dev/null
+++ b/tests/test_hashing.py
@@ -0,0 +1,68 @@
+import numpy
+import pytest
+from matchms import Spectrum
+from matchms.hashing import metadata_hash
+from matchms.hashing import spectrum_hash
+
+
+@pytest.fixture
+def spectrum():
+    return Spectrum(mz=numpy.array([100, 200, 290, 490, 490.5], dtype="float"),
+                    intensities=numpy.array([0.1, 0.11, 1.0, 0.3, 0.4], dtype="float"),
+                    metadata={"precursor_mz": 505.0})
+
+
+def test_spectrum_hash(spectrum):
+    generated_hash = spectrum_hash(spectrum.peaks)
+    assert len(generated_hash) == 20, "Expected hash of length 20."
+    assert generated_hash == "9b78750e231ff7ea020b", \
+        "Expected different hash"
+
+
+def test_spectrum_hash_changed_length(spectrum):
+    generated_hash = spectrum_hash(spectrum.peaks,
+                                   hash_length=15)
+    assert len(generated_hash) == 15, "Expected hash of length 15."
+    assert generated_hash == "9b78750e231ff7e", \
+        "Expected different hash"
+
+
+def test_spectrum_hash_changed_mz_precision(spectrum):
+    spectrum2 = Spectrum(mz=numpy.array([100.01, 200, 290, 490, 490.5], dtype="float"),
+                         intensities=numpy.array([0.1, 0.11, 1.0, 0.3, 0.4], dtype="float"),
+                         metadata={"precursor_mz": 505.0})
+    generated_hash_1a = spectrum_hash(spectrum.peaks,
+                                      mz_precision=1)
+
+    generated_hash_1b = spectrum_hash(spectrum2.peaks,
+                                      mz_precision=1)
+    assert generated_hash_1a == generated_hash_1b, \
+        "Expected hashes to be the same"
+    # mz_precision = 2
+    generated_hash_1a = spectrum_hash(spectrum.peaks,
+                                      mz_precision=2)
+
+    generated_hash_1b = spectrum_hash(spectrum2.peaks,
+                                      mz_precision=2)
+    assert generated_hash_1a != generated_hash_1b, \
+        "Expected hashes to be different"
+
+
+def test_spectrum_hash_changed_precision(spectrum):
+    generated_hash = spectrum_hash(spectrum.peaks,
+                                   intensity_precision=1)
+    assert generated_hash == "aef640708220769ce327", \
+        "Expected different hash"
+
+
+def test_metadata_hash(spectrum):
+    generated_hash = metadata_hash(spectrum.metadata)
+    assert generated_hash == "23da883766f6cdb37a35", \
+        "Expected different hash"
+
+
+def test_metadata_hash_changed_length(spectrum):
+    generated_hash = metadata_hash(spectrum.metadata,
+                                   hash_length=15)
+    assert generated_hash == "23da883766f6cdb", \
+        "Expected different hash"
diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py
index 32c2c373..6a64d3b3 100644
--- a/tests/test_spectrum.py
+++ b/tests/test_spectrum.py
@@ -88,6 +88,80 @@ def test_comparing_spectra_with_arrays():
     assert spectrum0 != spectrum1, "Expected spectra to not be equal"
 
 
+def test_spectrum_hash():
+    mz = numpy.array([100.00003, 110.2, 200.581], dtype='float')
+    intensities = numpy.array([0.51, 1.0, 0.011], dtype='float')
+    spectrum = Spectrum(mz=mz,
+                        intensities=intensities,
+                        metadata={"pepmass": (444.0, 11),
+                                  "charge": -1})
+    assert hash(spectrum) == 1516465757675504211, "Expected different hash."
+    assert spectrum.metadata_hash() == "92c0464af949ae56627f", \
+        "Expected different metadata hash."
+    assert spectrum.spectrum_hash() == "c79de5a8b333f780c206", \
+        "Expected different spectrum hash."
+
+
+def test_spectrum_hash_mz_sensitivity():
+    """Test is changes indeed lead to different hashes as expected."""
+    mz = numpy.array([100.00003, 110.2, 200.581], dtype='float')
+    intensities = numpy.array([0.51, 1.0, 0.011], dtype='float')
+    spectrum1 = Spectrum(mz=mz,
+                         intensities=intensities,
+                         metadata={"pepmass": (444.0, 11),
+                                   "charge": -1})
+    mz2 = mz.copy()
+    mz2[0] += 0.00001
+    spectrum2 = Spectrum(mz=mz2,
+                         intensities=intensities,
+                         metadata={"pepmass": (444.0, 11),
+                                   "charge": -1})
+    assert hash(spectrum1) != hash(spectrum2), "Expected hashes to be different."
+    assert spectrum1.metadata_hash() == spectrum2.metadata_hash(), \
+        "Expected metadata hashes to be unchanged."
+    assert spectrum1.spectrum_hash() != spectrum2.spectrum_hash(), \
+        "Expected spectrum hashes to be different."
+
+
+def test_spectrum_hash_intensity_sensitivity():
+    """Test is changes indeed lead to different hashes as expected."""
+    mz = numpy.array([100.00003, 110.2, 200.581], dtype='float')
+    intensities = numpy.array([0.51, 1.0, 0.011], dtype='float')
+    spectrum1 = Spectrum(mz=mz,
+                         intensities=intensities,
+                         metadata={"pepmass": (444.0, 11),
+                                   "charge": -1})
+    intensities2 = intensities.copy()
+    intensities2[0] += 0.01
+    spectrum2 = Spectrum(mz=mz,
+                         intensities=intensities2,
+                         metadata={"pepmass": (444.0, 11),
+                                   "charge": -1})
+    assert hash(spectrum1) != hash(spectrum2), "Expected hashes to be different."
+    assert spectrum1.metadata_hash() == spectrum2.metadata_hash(), \
+        "Expected metadata hashes to be unchanged."
+    assert spectrum1.spectrum_hash() != spectrum2.spectrum_hash(), \
+        "Expected hashes to be different."
+
+
+def test_spectrum_hash_metadata_sensitivity():
+    """Test is changes indeed lead to different hashes as expected."""
+    mz = numpy.array([100.00003, 110.2, 200.581], dtype='float')
+    intensities = numpy.array([0.51, 1.0, 0.011], dtype='float')
+    spectrum1 = Spectrum(mz=mz,
+                         intensities=intensities,
+                         metadata={"pepmass": (444.0, 11),
+                                   "charge": -1})
+    spectrum2 = spectrum1.clone()
+    spectrum2.set("pepmass", (444.1, 11))
+
+    assert hash(spectrum1) != hash(spectrum2), "Expected hashes to be different."
+    assert spectrum1.metadata_hash() != spectrum2.metadata_hash(), \
+        "Expected metadata hashes to be different."
+    assert spectrum1.spectrum_hash() == spectrum2.spectrum_hash(), \
+        "Expected hashes to be unchanged."
+
+
 def test_spectrum_plot_same_peak_height():
     intensities_with_zero_variance = numpy.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype="float")
     spectrum = _create_test_spectrum_with_intensities(intensities_with_zero_variance)