From 9dfe74e0431e0ee776b07f4719d6d60e23b2dc79 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Mon, 13 Mar 2023 16:33:28 +0000 Subject: [PATCH 01/40] Init commit --- speechmatics/metrics/README.md | 51 + speechmatics/metrics/normalizers/LICENSE | 21 + speechmatics/metrics/normalizers/__init__.py | 2 + speechmatics/metrics/normalizers/basic.py | 80 + speechmatics/metrics/normalizers/english.json | 1741 +++++++++++++++++ speechmatics/metrics/normalizers/english.py | 543 +++++ speechmatics/metrics/wer.py | 319 +++ 7 files changed, 2757 insertions(+) create mode 100644 speechmatics/metrics/README.md create mode 100644 speechmatics/metrics/normalizers/LICENSE create mode 100644 speechmatics/metrics/normalizers/__init__.py create mode 100644 speechmatics/metrics/normalizers/basic.py create mode 100644 speechmatics/metrics/normalizers/english.json create mode 100644 speechmatics/metrics/normalizers/english.py create mode 100644 speechmatics/metrics/wer.py diff --git a/speechmatics/metrics/README.md b/speechmatics/metrics/README.md new file mode 100644 index 0000000..1779864 --- /dev/null +++ b/speechmatics/metrics/README.md @@ -0,0 +1,51 @@ +# Speechmatics WER Benchmarking + +WER is a metric commonly used for benchmarking Automatic Speech Recognition accuracy. + +It compares a perfect Reference transcript against the ASR transcript, also known as the Hypothesis transcript. The metric itself measures the minimum number of edits required to correct the Hypothesis transcript into the perfect Reference transcript. The number of edits is normalised by the number of words in the Reference transcript, meaning that the WER can be compared across files with different number of words in. + +## Normalisation + +The Reference and Hypothesis transcripts are often normalised, so as not to penalise mistakes due to differences in capitalisation, punctuation or formatting. This can involve some of the following steps: + +1. Converting all letters to lowercase +2. Converting symbols to their written form (currencies, numbers and ordinals for instance) +3. Converting all whitespace to single spaces +4. Converting contractions to their written form (e.g., Dr. -> doctor) +5. Converting alternative spellings to a common form (e.g., colourise -> colorize) +6. Removing punctuation + +This is not an exhaustive list, and there will often be edge cases which need to be investigated. + +## Types of Errors + +Errors (or edits to the hypothesis) are categorised into Insertions, Deletions and Substitutions. + +- Insertions means the Hypothesis transcript contains words not in the original audio +- Deletions means the Hypothesis transcript didn't transcribe words that did appear in the original audio +- Substitutions means the Hypothesis transcript exchanged one word for another, compared with the original audio + +Adding up all the occurences of each type of error gives us the total number of errors, known as the Minimum Edit Distance or Levenshtein Distance. Dividing by the total number of words in the Reference, N, gives us the WER as follows: + +$$ \text{WER} = \frac{I + D + S}{N}$$ + +Accuracy is the complement of WER. That is, if the WER of an ASR transcript if 5 %, it's Accuracy would be 95 %, since 5 % + 95 % = 100 % + +## Usage + +This WER tool is built using the JiWER library. Install it as follows: + +```bash +pip3 install jiwer +``` + +To compute the WER and show a transcript highlighting the difference between the Reference and the Hypothesis, run the following: + +```bash +python3 wer.py --diff +``` + +## Read More + +- [The Future of Word Error Rate](https://www.speechmatics.com/company/articles-and-news/the-future-of-word-error-rate?utm_source=facebook&utm_medium=social&fbclid=IwAR1z7ZU4WowgDBs91MNKFTwPACD9gb7dkrQpkr1HmfsgXPv-Ndt5PeySjIk&restored=1676632411598) +- [Speech and Language Processing, Ch 2.](https://web.stanford.edu/~jurafsky/slp3/2.pdf) by Jurafsky and Martin diff --git a/speechmatics/metrics/normalizers/LICENSE b/speechmatics/metrics/normalizers/LICENSE new file mode 100644 index 0000000..d255525 --- /dev/null +++ b/speechmatics/metrics/normalizers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/speechmatics/metrics/normalizers/__init__.py b/speechmatics/metrics/normalizers/__init__.py new file mode 100644 index 0000000..0b10d5a --- /dev/null +++ b/speechmatics/metrics/normalizers/__init__.py @@ -0,0 +1,2 @@ +from .basic import BasicTextNormalizer +from .english import EnglishTextNormalizer diff --git a/speechmatics/metrics/normalizers/basic.py b/speechmatics/metrics/normalizers/basic.py new file mode 100644 index 0000000..84c3b39 --- /dev/null +++ b/speechmatics/metrics/normalizers/basic.py @@ -0,0 +1,80 @@ +import re +import unicodedata +import regex + +# non-ASCII letters that are not separated by "NFKD" normalization +ADDITIONAL_DIACRITICS = { + "œ": "oe", + "Œ": "OE", + "ø": "o", + "Ø": "O", + "æ": "ae", + "Æ": "AE", + "ß": "ss", + "ẞ": "SS", + "đ": "d", + "Đ": "D", + "ð": "d", + "Ð": "D", + "þ": "th", + "Þ": "th", + "ł": "l", + "Ł": "L", +} + + +def remove_symbols_and_diacritics(s: str, keep=""): + """ + Replace any other markers, symbols, and punctuations with a space, + and drop any diacritics (category 'Mn' and some manual mappings) + """ + return "".join( + c + if c in keep + else ADDITIONAL_DIACRITICS[c] + if c in ADDITIONAL_DIACRITICS + else "" + if unicodedata.category(c) == "Mn" + else " " + if unicodedata.category(c)[0] in "MSP" + else c + for c in unicodedata.normalize("NFKD", s) + ) + + +def remove_symbols(s: str): + """ + Replace any other markers, symbols, punctuations with a space, keeping diacritics + """ + return "".join( + " " if unicodedata.category(c)[0] in "MSP" else c for c in unicodedata.normalize("NFKC", s) + ) + + +class BasicTextNormalizer: + + def __init__(self, remove_diacritics: bool = False, split_letters: bool = False): + self.clean = remove_symbols_and_diacritics if remove_diacritics else remove_symbols + self.split_letters = split_letters + + def __call__(self, s: str) -> str: + """ + Take in a string. + Return a normalised string. + + Make everything lowercase. + Replace diacritics with the ASCII eqivalent. + Remove tokens between brackets. + Replace whitespace and any remaining punctuation with single space. + """ + s = s.lower() + s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) # remove words between brackets + s = re.sub(r"\(([^)]+?)\)", "", s) # remove words between parenthesis + s = self.clean(s).lower() + + if self.split_letters: + s = " ".join(regex.findall(r"\X", s, regex.U)) + + s = re.sub(r"\s+", " ", s) # replace any successive whitespace characters with a space + + return s diff --git a/speechmatics/metrics/normalizers/english.json b/speechmatics/metrics/normalizers/english.json new file mode 100644 index 0000000..74a1c35 --- /dev/null +++ b/speechmatics/metrics/normalizers/english.json @@ -0,0 +1,1741 @@ +{ + "accessorise": "accessorize", + "accessorised": "accessorized", + "accessorises": "accessorizes", + "accessorising": "accessorizing", + "acclimatisation": "acclimatization", + "acclimatise": "acclimatize", + "acclimatised": "acclimatized", + "acclimatises": "acclimatizes", + "acclimatising": "acclimatizing", + "accoutrements": "accouterments", + "aeon": "eon", + "aeons": "eons", + "aerogramme": "aerogram", + "aerogrammes": "aerograms", + "aeroplane": "airplane", + "aeroplanes": "airplanes", + "aesthete": "esthete", + "aesthetes": "esthetes", + "aesthetic": "esthetic", + "aesthetically": "esthetically", + "aesthetics": "esthetics", + "aetiology": "etiology", + "ageing": "aging", + "aggrandisement": "aggrandizement", + "agonise": "agonize", + "agonised": "agonized", + "agonises": "agonizes", + "agonising": "agonizing", + "agonisingly": "agonizingly", + "almanack": "almanac", + "almanacks": "almanacs", + "aluminium": "aluminum", + "amortisable": "amortizable", + "amortisation": "amortization", + "amortisations": "amortizations", + "amortise": "amortize", + "amortised": "amortized", + "amortises": "amortizes", + "amortising": "amortizing", + "amphitheatre": "amphitheater", + "amphitheatres": "amphitheaters", + "anaemia": "anemia", + "anaemic": "anemic", + "anaesthesia": "anesthesia", + "anaesthetic": "anesthetic", + "anaesthetics": "anesthetics", + "anaesthetise": "anesthetize", + "anaesthetised": "anesthetized", + "anaesthetises": "anesthetizes", + "anaesthetising": "anesthetizing", + "anaesthetist": "anesthetist", + "anaesthetists": "anesthetists", + "anaesthetize": "anesthetize", + "anaesthetized": "anesthetized", + "anaesthetizes": "anesthetizes", + "anaesthetizing": "anesthetizing", + "analogue": "analog", + "analogues": "analogs", + "analyse": "analyze", + "analysed": "analyzed", + "analyses": "analyzes", + "analysing": "analyzing", + "anglicise": "anglicize", + "anglicised": "anglicized", + "anglicises": "anglicizes", + "anglicising": "anglicizing", + "annualised": "annualized", + "antagonise": "antagonize", + "antagonised": "antagonized", + "antagonises": "antagonizes", + "antagonising": "antagonizing", + "apologise": "apologize", + "apologised": "apologized", + "apologises": "apologizes", + "apologising": "apologizing", + "appal": "appall", + "appals": "appalls", + "appetiser": "appetizer", + "appetisers": "appetizers", + "appetising": "appetizing", + "appetisingly": "appetizingly", + "arbour": "arbor", + "arbours": "arbors", + "archeological": "archaeological", + "archaeologically": "archeologically", + "archaeologist": "archeologist", + "archaeologists": "archeologists", + "archaeology": "archeology", + "ardour": "ardor", + "armour": "armor", + "armoured": "armored", + "armourer": "armorer", + "armourers": "armorers", + "armouries": "armories", + "armoury": "armory", + "artefact": "artifact", + "artefacts": "artifacts", + "authorise": "authorize", + "authorised": "authorized", + "authorises": "authorizes", + "authorising": "authorizing", + "axe": "ax", + "backpedalled": "backpedaled", + "backpedalling": "backpedaling", + "bannister": "banister", + "bannisters": "banisters", + "baptise": "baptize", + "baptised": "baptized", + "baptises": "baptizes", + "baptising": "baptizing", + "bastardise": "bastardize", + "bastardised": "bastardized", + "bastardises": "bastardizes", + "bastardising": "bastardizing", + "battleax": "battleaxe", + "baulk": "balk", + "baulked": "balked", + "baulking": "balking", + "baulks": "balks", + "bedevilled": "bedeviled", + "bedevilling": "bedeviling", + "behaviour": "behavior", + "behavioural": "behavioral", + "behaviourism": "behaviorism", + "behaviourist": "behaviorist", + "behaviourists": "behaviorists", + "behaviours": "behaviors", + "behove": "behoove", + "behoved": "behooved", + "behoves": "behooves", + "bejewelled": "bejeweled", + "belabour": "belabor", + "belaboured": "belabored", + "belabouring": "belaboring", + "belabours": "belabors", + "bevelled": "beveled", + "bevvies": "bevies", + "bevvy": "bevy", + "biassed": "biased", + "biassing": "biasing", + "bingeing": "binging", + "bougainvillaea": "bougainvillea", + "bougainvillaeas": "bougainvilleas", + "bowdlerise": "bowdlerize", + "bowdlerised": "bowdlerized", + "bowdlerises": "bowdlerizes", + "bowdlerising": "bowdlerizing", + "breathalyse": "breathalyze", + "breathalysed": "breathalyzed", + "breathalyser": "breathalyzer", + "breathalysers": "breathalyzers", + "breathalyses": "breathalyzes", + "breathalysing": "breathalyzing", + "brutalise": "brutalize", + "brutalised": "brutalized", + "brutalises": "brutalizes", + "brutalising": "brutalizing", + "busses": "buses", + "bussing": "busing", + "caesarean": "cesarean", + "caesareans": "cesareans", + "calibre": "caliber", + "calibres": "calibers", + "calliper": "caliper", + "callipers": "calipers", + "callisthenics": "calisthenics", + "canalise": "canalize", + "canalised": "canalized", + "canalises": "canalizes", + "canalising": "canalizing", + "cancelation": "cancellation", + "cancelations": "cancellations", + "cancelled": "canceled", + "cancelling": "canceling", + "candour": "candor", + "cannibalise": "cannibalize", + "cannibalised": "cannibalized", + "cannibalises": "cannibalizes", + "cannibalising": "cannibalizing", + "canonise": "canonize", + "canonised": "canonized", + "canonises": "canonizes", + "canonising": "canonizing", + "capitalise": "capitalize", + "capitalised": "capitalized", + "capitalises": "capitalizes", + "capitalising": "capitalizing", + "caramelise": "caramelize", + "caramelised": "caramelized", + "caramelises": "caramelizes", + "caramelising": "caramelizing", + "carbonise": "carbonize", + "carbonised": "carbonized", + "carbonises": "carbonizes", + "carbonising": "carbonizing", + "carolled": "caroled", + "carolling": "caroling", + "catalogue": "catalog", + "catalogued": "cataloged", + "catalogues": "catalogs", + "cataloguing": "cataloging", + "catalyse": "catalyze", + "catalysed": "catalyzed", + "catalyses": "catalyzes", + "catalysing": "catalyzing", + "categorise": "categorize", + "categorised": "categorized", + "categorises": "categorizes", + "categorising": "categorizing", + "cauterise": "cauterize", + "cauterised": "cauterized", + "cauterises": "cauterizes", + "cauterising": "cauterizing", + "cavilled": "caviled", + "cavilling": "caviling", + "centigramme": "centigram", + "centigrammes": "centigrams", + "centilitre": "centiliter", + "centilitres": "centiliters", + "centimetre": "centimeter", + "centimetres": "centimeters", + "centralise": "centralize", + "centralised": "centralized", + "centralises": "centralizes", + "centralising": "centralizing", + "centre": "center", + "centred": "centered", + "centrefold": "centerfold", + "centrefolds": "centerfolds", + "centrepiece": "centerpiece", + "centrepieces": "centerpieces", + "centres": "centers", + "channelled": "channeled", + "channelling": "channeling", + "characterise": "characterize", + "characterised": "characterized", + "characterises": "characterizes", + "characterising": "characterizing", + "cheque": "check", + "chequebook": "checkbook", + "chequebooks": "checkbooks", + "chequered": "checkered", + "cheques": "checks", + "chilli": "chili", + "chimaera": "chimera", + "chimaeras": "chimeras", + "chiselled": "chiseled", + "chiselling": "chiseling", + "circularise": "circularize", + "circularised": "circularized", + "circularises": "circularizes", + "circularising": "circularizing", + "civilise": "civilize", + "civilised": "civilized", + "civilises": "civilizes", + "civilising": "civilizing", + "clamour": "clamor", + "clamoured": "clamored", + "clamouring": "clamoring", + "clamours": "clamors", + "clangour": "clangor", + "clarinettist": "clarinetist", + "clarinettists": "clarinetists", + "collectivise": "collectivize", + "collectivised": "collectivized", + "collectivises": "collectivizes", + "collectivising": "collectivizing", + "colonisation": "colonization", + "colonise": "colonize", + "colonised": "colonized", + "coloniser": "colonizer", + "colonisers": "colonizers", + "colonises": "colonizes", + "colonising": "colonizing", + "colour": "color", + "colourant": "colorant", + "colourants": "colorants", + "coloured": "colored", + "coloureds": "coloreds", + "colourful": "colorful", + "colourfully": "colorfully", + "colouring": "coloring", + "colourize": "colorize", + "colourized": "colorized", + "colourizes": "colorizes", + "colourizing": "colorizing", + "colourless": "colorless", + "colours": "colors", + "commercialise": "commercialize", + "commercialised": "commercialized", + "commercialises": "commercializes", + "commercialising": "commercializing", + "compartmentalise": "compartmentalize", + "compartmentalised": "compartmentalized", + "compartmentalises": "compartmentalizes", + "compartmentalising": "compartmentalizing", + "computerise": "computerize", + "computerised": "computerized", + "computerises": "computerizes", + "computerising": "computerizing", + "conceptualise": "conceptualize", + "conceptualised": "conceptualized", + "conceptualises": "conceptualizes", + "conceptualising": "conceptualizing", + "connexion": "connection", + "connexions": "connections", + "contextualise": "contextualize", + "contextualised": "contextualized", + "contextualises": "contextualizes", + "contextualising": "contextualizing", + "cosier": "cozier", + "cosies": "cozies", + "cosiest": "coziest", + "cosily": "cozily", + "cosiness": "coziness", + "cosy": "cozy", + "councillor": "councilor", + "councillors": "councilors", + "counselled": "counseled", + "counselling": "counseling", + "counsellor": "counselor", + "counsellors": "counselors", + "crenelated": "crenellated", + "criminalise": "criminalize", + "criminalised": "criminalized", + "criminalises": "criminalizes", + "criminalising": "criminalizing", + "criticise": "criticize", + "criticised": "criticized", + "criticises": "criticizes", + "criticising": "criticizing", + "crueller": "crueler", + "cruellest": "cruelest", + "crystallisation": "crystallization", + "crystallise": "crystallize", + "crystallised": "crystallized", + "crystallises": "crystallizes", + "crystallising": "crystallizing", + "cudgelled": "cudgeled", + "cudgelling": "cudgeling", + "customise": "customize", + "customised": "customized", + "customises": "customizes", + "customising": "customizing", + "cypher": "cipher", + "cyphers": "ciphers", + "decentralisation": "decentralization", + "decentralise": "decentralize", + "decentralised": "decentralized", + "decentralises": "decentralizes", + "decentralising": "decentralizing", + "decriminalisation": "decriminalization", + "decriminalise": "decriminalize", + "decriminalised": "decriminalized", + "decriminalises": "decriminalizes", + "decriminalising": "decriminalizing", + "defence": "defense", + "defenceless": "defenseless", + "defences": "defenses", + "dehumanisation": "dehumanization", + "dehumanise": "dehumanize", + "dehumanised": "dehumanized", + "dehumanises": "dehumanizes", + "dehumanising": "dehumanizing", + "demeanour": "demeanor", + "demilitarisation": "demilitarization", + "demilitarise": "demilitarize", + "demilitarised": "demilitarized", + "demilitarises": "demilitarizes", + "demilitarising": "demilitarizing", + "demobilisation": "demobilization", + "demobilise": "demobilize", + "demobilised": "demobilized", + "demobilises": "demobilizes", + "demobilising": "demobilizing", + "democratisation": "democratization", + "democratise": "democratize", + "democratised": "democratized", + "democratises": "democratizes", + "democratising": "democratizing", + "demonise": "demonize", + "demonised": "demonized", + "demonises": "demonizes", + "demonising": "demonizing", + "demoralisation": "demoralization", + "demoralise": "demoralize", + "demoralised": "demoralized", + "demoralises": "demoralizes", + "demoralising": "demoralizing", + "denationalisation": "denationalization", + "denationalise": "denationalize", + "denationalised": "denationalized", + "denationalises": "denationalizes", + "denationalising": "denationalizing", + "deodorise": "deodorize", + "deodorised": "deodorized", + "deodorises": "deodorizes", + "deodorising": "deodorizing", + "depersonalise": "depersonalize", + "depersonalised": "depersonalized", + "depersonalises": "depersonalizes", + "depersonalising": "depersonalizing", + "deputise": "deputize", + "deputised": "deputized", + "deputises": "deputizes", + "deputising": "deputizing", + "desensitisation": "desensitization", + "desensitise": "desensitize", + "desensitised": "desensitized", + "desensitises": "desensitizes", + "desensitising": "desensitizing", + "destabilisation": "destabilization", + "destabilise": "destabilize", + "destabilised": "destabilized", + "destabilises": "destabilizes", + "destabilising": "destabilizing", + "dialled": "dialed", + "dialling": "dialing", + "dialogue": "dialog", + "dialogues": "dialogs", + "diarrhoea": "diarrhea", + "digitise": "digitize", + "digitised": "digitized", + "digitises": "digitizes", + "digitising": "digitizing", + "disc": "disk", + "discolour": "discolor", + "discoloured": "discolored", + "discolouring": "discoloring", + "discolours": "discolors", + "discs": "disks", + "disembowelled": "disemboweled", + "disembowelling": "disemboweling", + "disfavour": "disfavor", + "dishevelled": "disheveled", + "dishonour": "dishonor", + "dishonourable": "dishonorable", + "dishonourably": "dishonorably", + "dishonoured": "dishonored", + "dishonouring": "dishonoring", + "dishonours": "dishonors", + "disorganisation": "disorganization", + "disorganised": "disorganized", + "distil": "distill", + "distils": "distills", + "dramatisation": "dramatization", + "dramatisations": "dramatizations", + "dramatise": "dramatize", + "dramatised": "dramatized", + "dramatises": "dramatizes", + "dramatising": "dramatizing", + "draught": "draft", + "draughtboard": "draftboard", + "draughtboards": "draftboards", + "draughtier": "draftier", + "draughtiest": "draftiest", + "draughts": "drafts", + "draughtsman": "draftsman", + "draughtsmanship": "draftsmanship", + "draughtsmen": "draftsmen", + "draughtswoman": "draftswoman", + "draughtswomen": "draftswomen", + "draughty": "drafty", + "drivelled": "driveled", + "drivelling": "driveling", + "duelled": "dueled", + "duelling": "dueling", + "economise": "economize", + "economised": "economized", + "economises": "economizes", + "economising": "economizing", + "edoema": "edema", + "editorialise": "editorialize", + "editorialised": "editorialized", + "editorialises": "editorializes", + "editorialising": "editorializing", + "empathise": "empathize", + "empathised": "empathized", + "empathises": "empathizes", + "empathising": "empathizing", + "emphasise": "emphasize", + "emphasised": "emphasized", + "emphasises": "emphasizes", + "emphasising": "emphasizing", + "enamelled": "enameled", + "enamelling": "enameling", + "enamoured": "enamored", + "encyclopaedia": "encyclopedia", + "encyclopaedias": "encyclopedias", + "encyclopaedic": "encyclopedic", + "endeavour": "endeavor", + "endeavoured": "endeavored", + "endeavouring": "endeavoring", + "endeavours": "endeavors", + "energise": "energize", + "energised": "energized", + "energises": "energizes", + "energising": "energizing", + "enrol": "enroll", + "enrols": "enrolls", + "enthral": "enthrall", + "enthrals": "enthralls", + "epaulette": "epaulet", + "epaulettes": "epaulets", + "epicentre": "epicenter", + "epicentres": "epicenters", + "epilogue": "epilog", + "epilogues": "epilogs", + "epitomise": "epitomize", + "epitomised": "epitomized", + "epitomises": "epitomizes", + "epitomising": "epitomizing", + "equalisation": "equalization", + "equalise": "equalize", + "equalised": "equalized", + "equaliser": "equalizer", + "equalisers": "equalizers", + "equalises": "equalizes", + "equalising": "equalizing", + "eulogise": "eulogize", + "eulogised": "eulogized", + "eulogises": "eulogizes", + "eulogising": "eulogizing", + "evangelise": "evangelize", + "evangelised": "evangelized", + "evangelises": "evangelizes", + "evangelising": "evangelizing", + "exorcise": "exorcize", + "exorcised": "exorcized", + "exorcises": "exorcizes", + "exorcising": "exorcizing", + "extemporisation": "extemporization", + "extemporise": "extemporize", + "extemporised": "extemporized", + "extemporises": "extemporizes", + "extemporising": "extemporizing", + "externalisation": "externalization", + "externalisations": "externalizations", + "externalise": "externalize", + "externalised": "externalized", + "externalises": "externalizes", + "externalising": "externalizing", + "factorise": "factorize", + "factorised": "factorized", + "factorises": "factorizes", + "factorising": "factorizing", + "faecal": "fecal", + "faeces": "feces", + "familiarisation": "familiarization", + "familiarise": "familiarize", + "familiarised": "familiarized", + "familiarises": "familiarizes", + "familiarising": "familiarizing", + "fantasise": "fantasize", + "fantasised": "fantasized", + "fantasises": "fantasizes", + "fantasising": "fantasizing", + "favour": "favor", + "favourable": "favorable", + "favourably": "favorably", + "favoured": "favored", + "favouring": "favoring", + "favourite": "favorite", + "favourites": "favorites", + "favouritism": "favoritism", + "favours": "favors", + "feminise": "feminize", + "feminised": "feminized", + "feminises": "feminizes", + "feminising": "feminizing", + "fertilisation": "fertilization", + "fertilise": "fertilize", + "fertilised": "fertilized", + "fertiliser": "fertilizer", + "fertilisers": "fertilizers", + "fertilises": "fertilizes", + "fertilising": "fertilizing", + "fervour": "fervor", + "fibre": "fiber", + "fibreglass": "fiberglass", + "fibres": "fibers", + "fictionalisation": "fictionalization", + "fictionalisations": "fictionalizations", + "fictionalise": "fictionalize", + "fictionalised": "fictionalized", + "fictionalises": "fictionalizes", + "fictionalising": "fictionalizing", + "fillet": "filet", + "filleted": "fileted", + "filleting": "fileting", + "fillets": "filets", + "finalisation": "finalization", + "finalise": "finalize", + "finalised": "finalized", + "finalises": "finalizes", + "finalising": "finalizing", + "flautist": "flutist", + "flautists": "flutists", + "flavour": "flavor", + "flavoured": "flavored", + "flavouring": "flavoring", + "flavourings": "flavorings", + "flavourless": "flavorless", + "flavours": "flavors", + "flavoursome": "flavorsome", + "flyer / flier": "flier / flyer", + "foetal": "fetal", + "foetid": "fetid", + "foetus": "fetus", + "foetuses": "fetuses", + "formalisation": "formalization", + "formalise": "formalize", + "formalised": "formalized", + "formalises": "formalizes", + "formalising": "formalizing", + "fossilisation": "fossilization", + "fossilise": "fossilize", + "fossilised": "fossilized", + "fossilises": "fossilizes", + "fossilising": "fossilizing", + "fraternisation": "fraternization", + "fraternise": "fraternize", + "fraternised": "fraternized", + "fraternises": "fraternizes", + "fraternising": "fraternizing", + "fulfil": "fulfill", + "fulfilment": "fulfillment", + "fulfils": "fulfills", + "funnelled": "funneled", + "funnelling": "funneling", + "galvanise": "galvanize", + "galvanised": "galvanized", + "galvanises": "galvanizes", + "galvanising": "galvanizing", + "gambolled": "gamboled", + "gambolling": "gamboling", + "gaol": "jail", + "gaolbird": "jailbird", + "gaolbirds": "jailbirds", + "gaolbreak": "jailbreak", + "gaolbreaks": "jailbreaks", + "gaoled": "jailed", + "gaoler": "jailer", + "gaolers": "jailers", + "gaoling": "jailing", + "gaols": "jails", + "gasses": "gases", + "gage": "gauge", + "gaged": "gauged", + "gages": "gauges", + "gaging": "gauging", + "generalisation": "generalization", + "generalisations": "generalizations", + "generalise": "generalize", + "generalised": "generalized", + "generalises": "generalizes", + "generalising": "generalizing", + "ghettoise": "ghettoize", + "ghettoised": "ghettoized", + "ghettoises": "ghettoizes", + "ghettoising": "ghettoizing", + "gipsies": "gypsies", + "glamorise": "glamorize", + "glamorised": "glamorized", + "glamorises": "glamorizes", + "glamorising": "glamorizing", + "glamor": "glamour", + "globalisation": "globalization", + "globalise": "globalize", + "globalised": "globalized", + "globalises": "globalizes", + "globalising": "globalizing", + "glueing": "gluing", + "goitre": "goiter", + "goitres": "goiters", + "gonorrhoea": "gonorrhea", + "gramme": "gram", + "grammes": "grams", + "gravelled": "graveled", + "grey": "gray", + "greyed": "grayed", + "greying": "graying", + "greyish": "grayish", + "greyness": "grayness", + "greys": "grays", + "grovelled": "groveled", + "grovelling": "groveling", + "groyne": "groin", + "groynes": "groins", + "gruelling": "grueling", + "gruellingly": "gruelingly", + "gryphon": "griffin", + "gryphons": "griffins", + "gynaecological": "gynecological", + "gynaecologist": "gynecologist", + "gynaecologists": "gynecologists", + "gynaecology": "gynecology", + "haematological": "hematological", + "haematologist": "hematologist", + "haematologists": "hematologists", + "haematology": "hematology", + "haemoglobin": "hemoglobin", + "haemophilia": "hemophilia", + "haemophiliac": "hemophiliac", + "haemophiliacs": "hemophiliacs", + "haemorrhage": "hemorrhage", + "haemorrhaged": "hemorrhaged", + "haemorrhages": "hemorrhages", + "haemorrhaging": "hemorrhaging", + "haemorrhoids": "hemorrhoids", + "harbour": "harbor", + "harboured": "harbored", + "harbouring": "harboring", + "harbours": "harbors", + "harmonisation": "harmonization", + "harmonise": "harmonize", + "harmonised": "harmonized", + "harmonises": "harmonizes", + "harmonising": "harmonizing", + "homoeopath": "homeopath", + "homoeopathic": "homeopathic", + "homoeopaths": "homeopaths", + "homoeopathy": "homeopathy", + "homogenise": "homogenize", + "homogenised": "homogenized", + "homogenises": "homogenizes", + "homogenising": "homogenizing", + "honour": "honor", + "honourable": "honorable", + "honourably": "honorably", + "honoured": "honored", + "honouring": "honoring", + "honours": "honors", + "hospitalisation": "hospitalization", + "hospitalise": "hospitalize", + "hospitalised": "hospitalized", + "hospitalises": "hospitalizes", + "hospitalising": "hospitalizing", + "humanise": "humanize", + "humanised": "humanized", + "humanises": "humanizes", + "humanising": "humanizing", + "humour": "humor", + "humoured": "humored", + "humouring": "humoring", + "humourless": "humorless", + "humours": "humors", + "hybridise": "hybridize", + "hybridised": "hybridized", + "hybridises": "hybridizes", + "hybridising": "hybridizing", + "hypnotise": "hypnotize", + "hypnotised": "hypnotized", + "hypnotises": "hypnotizes", + "hypnotising": "hypnotizing", + "hypothesise": "hypothesize", + "hypothesised": "hypothesized", + "hypothesises": "hypothesizes", + "hypothesising": "hypothesizing", + "idealisation": "idealization", + "idealise": "idealize", + "idealised": "idealized", + "idealises": "idealizes", + "idealising": "idealizing", + "idolise": "idolize", + "idolised": "idolized", + "idolises": "idolizes", + "idolising": "idolizing", + "immobilisation": "immobilization", + "immobilise": "immobilize", + "immobilised": "immobilized", + "immobiliser": "immobilizer", + "immobilisers": "immobilizers", + "immobilises": "immobilizes", + "immobilising": "immobilizing", + "immortalise": "immortalize", + "immortalised": "immortalized", + "immortalises": "immortalizes", + "immortalising": "immortalizing", + "immunisation": "immunization", + "immunise": "immunize", + "immunised": "immunized", + "immunises": "immunizes", + "immunising": "immunizing", + "impanelled": "impaneled", + "impanelling": "impaneling", + "imperilled": "imperiled", + "imperilling": "imperiling", + "individualise": "individualize", + "individualised": "individualized", + "individualises": "individualizes", + "individualising": "individualizing", + "industrialise": "industrialize", + "industrialised": "industrialized", + "industrialises": "industrializes", + "industrialising": "industrializing", + "inflexion": "inflection", + "inflexions": "inflections", + "initialise": "initialize", + "initialised": "initialized", + "initialises": "initializes", + "initialising": "initializing", + "initialled": "initialed", + "initialling": "initialing", + "instal": "install", + "instalment": "installment", + "instalments": "installments", + "instals": "installs", + "instil": "instill", + "instils": "instills", + "institutionalisation": "institutionalization", + "institutionalise": "institutionalize", + "institutionalised": "institutionalized", + "institutionalises": "institutionalizes", + "institutionalising": "institutionalizing", + "intellectualise": "intellectualize", + "intellectualised": "intellectualized", + "intellectualises": "intellectualizes", + "intellectualising": "intellectualizing", + "internalisation": "internalization", + "internalise": "internalize", + "internalised": "internalized", + "internalises": "internalizes", + "internalising": "internalizing", + "internationalisation": "internationalization", + "internationalise": "internationalize", + "internationalised": "internationalized", + "internationalises": "internationalizes", + "internationalising": "internationalizing", + "ionisation": "ionization", + "ionise": "ionize", + "ionised": "ionized", + "ioniser": "ionizer", + "ionisers": "ionizers", + "ionises": "ionizes", + "ionising": "ionizing", + "italicise": "italicize", + "italicised": "italicized", + "italicises": "italicizes", + "italicising": "italicizing", + "itemise": "itemize", + "itemised": "itemized", + "itemises": "itemizes", + "itemising": "itemizing", + "jeopardise": "jeopardize", + "jeopardised": "jeopardized", + "jeopardises": "jeopardizes", + "jeopardising": "jeopardizing", + "jewelled": "jeweled", + "jeweller": "jeweler", + "jewellers": "jewelers", + "jewellery": "jewelry", + "judgement": "judgment", + "kilogramme": "kilogram", + "kilogrammes": "kilograms", + "kilometre": "kilometer", + "kilometres": "kilometers", + "labelled": "labeled", + "labelling": "labeling", + "labour": "labor", + "laboured": "labored", + "labourer": "laborer", + "labourers": "laborers", + "labouring": "laboring", + "labours": "labors", + "lacklustre": "lackluster", + "legalisation": "legalization", + "legalise": "legalize", + "legalised": "legalized", + "legalises": "legalizes", + "legalising": "legalizing", + "legitimise": "legitimize", + "legitimised": "legitimized", + "legitimises": "legitimizes", + "legitimising": "legitimizing", + "leukaemia": "leukemia", + "levelled": "leveled", + "leveller": "leveler", + "levellers": "levelers", + "levelling": "leveling", + "libelled": "libeled", + "libelling": "libeling", + "libellous": "libelous", + "liberalisation": "liberalization", + "liberalise": "liberalize", + "liberalised": "liberalized", + "liberalises": "liberalizes", + "liberalising": "liberalizing", + "licence": "license", + "licenced": "licensed", + "licences": "licenses", + "licencing": "licensing", + "likeable": "likable", + "lionisation": "lionization", + "lionise": "lionize", + "lionised": "lionized", + "lionises": "lionizes", + "lionising": "lionizing", + "liquidise": "liquidize", + "liquidised": "liquidized", + "liquidiser": "liquidizer", + "liquidisers": "liquidizers", + "liquidises": "liquidizes", + "liquidising": "liquidizing", + "litre": "liter", + "litres": "liters", + "localise": "localize", + "localised": "localized", + "localises": "localizes", + "localising": "localizing", + "louvre": "louver", + "louvred": "louvered", + "louvres": "louvers", + "lustre": "luster", + "magnetise": "magnetize", + "magnetised": "magnetized", + "magnetises": "magnetizes", + "magnetising": "magnetizing", + "manoeuvrability": "maneuverability", + "manoeuvrable": "maneuverable", + "manoeuvre": "maneuver", + "manoeuvred": "maneuvered", + "manoeuvres": "maneuvers", + "manoeuvring": "maneuvering", + "manoeuvrings": "maneuverings", + "marginalisation": "marginalization", + "marginalise": "marginalize", + "marginalised": "marginalized", + "marginalises": "marginalizes", + "marginalising": "marginalizing", + "marshalled": "marshaled", + "marshalling": "marshaling", + "marvelled": "marveled", + "marvelling": "marveling", + "marvellous": "marvelous", + "marvellously": "marvelously", + "materialisation": "materialization", + "materialise": "materialize", + "materialised": "materialized", + "materialises": "materializes", + "materialising": "materializing", + "maximisation": "maximization", + "maximise": "maximize", + "maximised": "maximized", + "maximises": "maximizes", + "maximising": "maximizing", + "meagre": "meager", + "mechanisation": "mechanization", + "mechanise": "mechanize", + "mechanised": "mechanized", + "mechanises": "mechanizes", + "mechanising": "mechanizing", + "mediaeval": "medieval", + "memorialise": "memorialize", + "memorialised": "memorialized", + "memorialises": "memorializes", + "memorialising": "memorializing", + "memorise": "memorize", + "memorised": "memorized", + "memorises": "memorizes", + "memorising": "memorizing", + "mesmerise": "mesmerize", + "mesmerised": "mesmerized", + "mesmerises": "mesmerizes", + "mesmerising": "mesmerizing", + "metabolise": "metabolize", + "metabolised": "metabolized", + "metabolises": "metabolizes", + "metabolising": "metabolizing", + "metre": "meter", + "metres": "meters", + "micrometre": "micrometer", + "micrometres": "micrometers", + "militarise": "militarize", + "militarised": "militarized", + "militarises": "militarizes", + "militarising": "militarizing", + "milligramme": "milligram", + "milligrammes": "milligrams", + "millilitre": "milliliter", + "millilitres": "milliliters", + "millimetre": "millimeter", + "millimetres": "millimeters", + "miniaturisation": "miniaturization", + "miniaturise": "miniaturize", + "miniaturised": "miniaturized", + "miniaturises": "miniaturizes", + "miniaturising": "miniaturizing", + "minibusses": "minibuses", + "minimise": "minimize", + "minimised": "minimized", + "minimises": "minimizes", + "minimising": "minimizing", + "misbehaviour": "misbehavior", + "misdemeanour": "misdemeanor", + "misdemeanours": "misdemeanors", + "misspelt": "misspelled", + "mitre": "miter", + "mitres": "miters", + "mobilisation": "mobilization", + "mobilise": "mobilize", + "mobilised": "mobilized", + "mobilises": "mobilizes", + "mobilising": "mobilizing", + "modelled": "modeled", + "modeller": "modeler", + "modellers": "modelers", + "modelling": "modeling", + "modernise": "modernize", + "modernised": "modernized", + "modernises": "modernizes", + "modernising": "modernizing", + "moisturise": "moisturize", + "moisturised": "moisturized", + "moisturiser": "moisturizer", + "moisturisers": "moisturizers", + "moisturises": "moisturizes", + "moisturising": "moisturizing", + "monologue": "monolog", + "monologues": "monologs", + "monopolisation": "monopolization", + "monopolise": "monopolize", + "monopolised": "monopolized", + "monopolises": "monopolizes", + "monopolising": "monopolizing", + "moralise": "moralize", + "moralised": "moralized", + "moralises": "moralizes", + "moralising": "moralizing", + "motorised": "motorized", + "mould": "mold", + "moulded": "molded", + "moulder": "molder", + "mouldered": "moldered", + "mouldering": "moldering", + "moulders": "molders", + "mouldier": "moldier", + "mouldiest": "moldiest", + "moulding": "molding", + "mouldings": "moldings", + "moulds": "molds", + "mouldy": "moldy", + "moult": "molt", + "moulted": "molted", + "moulting": "molting", + "moults": "molts", + "moustache": "mustache", + "moustached": "mustached", + "moustaches": "mustaches", + "moustachioed": "mustachioed", + "multicoloured": "multicolored", + "nationalisation": "nationalization", + "nationalisations": "nationalizations", + "nationalise": "nationalize", + "nationalised": "nationalized", + "nationalises": "nationalizes", + "nationalising": "nationalizing", + "naturalisation": "naturalization", + "naturalise": "naturalize", + "naturalised": "naturalized", + "naturalises": "naturalizes", + "naturalising": "naturalizing", + "neighbour": "neighbor", + "neighbourhood": "neighborhood", + "neighbourhoods": "neighborhoods", + "neighbouring": "neighboring", + "neighbourliness": "neighborliness", + "neighbourly": "neighborly", + "neighbours": "neighbors", + "neutralisation": "neutralization", + "neutralise": "neutralize", + "neutralised": "neutralized", + "neutralises": "neutralizes", + "neutralising": "neutralizing", + "normalisation": "normalization", + "normalise": "normalize", + "normalised": "normalized", + "normalises": "normalizes", + "normalising": "normalizing", + "odour": "odor", + "odourless": "odorless", + "odours": "odors", + "oesophagus": "esophagus", + "oesophaguses": "esophaguses", + "oestrogen": "estrogen", + "offence": "offense", + "offences": "offenses", + "omelette": "omelet", + "omelettes": "omelets", + "optimise": "optimize", + "optimised": "optimized", + "optimises": "optimizes", + "optimising": "optimizing", + "organisation": "organization", + "organisational": "organizational", + "organisations": "organizations", + "organise": "organize", + "organised": "organized", + "organiser": "organizer", + "organisers": "organizers", + "organises": "organizes", + "organising": "organizing", + "orthopaedic": "orthopedic", + "orthopaedics": "orthopedics", + "ostracise": "ostracize", + "ostracised": "ostracized", + "ostracises": "ostracizes", + "ostracising": "ostracizing", + "outmanoeuvre": "outmaneuver", + "outmanoeuvred": "outmaneuvered", + "outmanoeuvres": "outmaneuvers", + "outmanoeuvring": "outmaneuvering", + "overemphasise": "overemphasize", + "overemphasised": "overemphasized", + "overemphasises": "overemphasizes", + "overemphasising": "overemphasizing", + "oxidisation": "oxidization", + "oxidise": "oxidize", + "oxidised": "oxidized", + "oxidises": "oxidizes", + "oxidising": "oxidizing", + "paederast": "pederast", + "paederasts": "pederasts", + "paediatric": "pediatric", + "paediatrician": "pediatrician", + "paediatricians": "pediatricians", + "paediatrics": "pediatrics", + "paedophile": "pedophile", + "paedophiles": "pedophiles", + "paedophilia": "pedophilia", + "palaeolithic": "paleolithic", + "palaeontologist": "paleontologist", + "palaeontologists": "paleontologists", + "palaeontology": "paleontology", + "panelled": "paneled", + "panelling": "paneling", + "panellist": "panelist", + "panellists": "panelists", + "paralyse": "paralyze", + "paralysed": "paralyzed", + "paralyses": "paralyzes", + "paralysing": "paralyzing", + "parcelled": "parceled", + "parcelling": "parceling", + "parlour": "parlor", + "parlours": "parlors", + "particularise": "particularize", + "particularised": "particularized", + "particularises": "particularizes", + "particularising": "particularizing", + "passivisation": "passivization", + "passivise": "passivize", + "passivised": "passivized", + "passivises": "passivizes", + "passivising": "passivizing", + "pasteurisation": "pasteurization", + "pasteurise": "pasteurize", + "pasteurised": "pasteurized", + "pasteurises": "pasteurizes", + "pasteurising": "pasteurizing", + "patronise": "patronize", + "patronised": "patronized", + "patronises": "patronizes", + "patronising": "patronizing", + "patronisingly": "patronizingly", + "pedalled": "pedaled", + "pedalling": "pedaling", + "pedestrianisation": "pedestrianization", + "pedestrianise": "pedestrianize", + "pedestrianised": "pedestrianized", + "pedestrianises": "pedestrianizes", + "pedestrianising": "pedestrianizing", + "penalise": "penalize", + "penalised": "penalized", + "penalises": "penalizes", + "penalising": "penalizing", + "pencilled": "penciled", + "pencilling": "penciling", + "personalise": "personalize", + "personalised": "personalized", + "personalises": "personalizes", + "personalising": "personalizing", + "pharmacopoeia": "pharmacopeia", + "pharmacopoeias": "pharmacopeias", + "philosophise": "philosophize", + "philosophised": "philosophized", + "philosophises": "philosophizes", + "philosophising": "philosophizing", + "philtre": "filter", + "philtres": "filters", + "phoney": "phony", + "plagiarise": "plagiarize", + "plagiarised": "plagiarized", + "plagiarises": "plagiarizes", + "plagiarising": "plagiarizing", + "plough": "plow", + "ploughed": "plowed", + "ploughing": "plowing", + "ploughman": "plowman", + "ploughmen": "plowmen", + "ploughs": "plows", + "ploughshare": "plowshare", + "ploughshares": "plowshares", + "polarisation": "polarization", + "polarise": "polarize", + "polarised": "polarized", + "polarises": "polarizes", + "polarising": "polarizing", + "politicisation": "politicization", + "politicise": "politicize", + "politicised": "politicized", + "politicises": "politicizes", + "politicising": "politicizing", + "popularisation": "popularization", + "popularise": "popularize", + "popularised": "popularized", + "popularises": "popularizes", + "popularising": "popularizing", + "pouffe": "pouf", + "pouffes": "poufs", + "practise": "practice", + "practised": "practiced", + "practises": "practices", + "practising": "practicing", + "praesidium": "presidium", + "praesidiums": "presidiums", + "pressurisation": "pressurization", + "pressurise": "pressurize", + "pressurised": "pressurized", + "pressurises": "pressurizes", + "pressurising": "pressurizing", + "pretence": "pretense", + "pretences": "pretenses", + "primaeval": "primeval", + "prioritisation": "prioritization", + "prioritise": "prioritize", + "prioritised": "prioritized", + "prioritises": "prioritizes", + "prioritising": "prioritizing", + "privatisation": "privatization", + "privatisations": "privatizations", + "privatise": "privatize", + "privatised": "privatized", + "privatises": "privatizes", + "privatising": "privatizing", + "professionalisation": "professionalization", + "professionalise": "professionalize", + "professionalised": "professionalized", + "professionalises": "professionalizes", + "professionalising": "professionalizing", + "programme": "program", + "programmes": "programs", + "prologue": "prolog", + "prologues": "prologs", + "propagandise": "propagandize", + "propagandised": "propagandized", + "propagandises": "propagandizes", + "propagandising": "propagandizing", + "proselytise": "proselytize", + "proselytised": "proselytized", + "proselytiser": "proselytizer", + "proselytisers": "proselytizers", + "proselytises": "proselytizes", + "proselytising": "proselytizing", + "psychoanalyse": "psychoanalyze", + "psychoanalysed": "psychoanalyzed", + "psychoanalyses": "psychoanalyzes", + "psychoanalysing": "psychoanalyzing", + "publicise": "publicize", + "publicised": "publicized", + "publicises": "publicizes", + "publicising": "publicizing", + "pulverisation": "pulverization", + "pulverise": "pulverize", + "pulverised": "pulverized", + "pulverises": "pulverizes", + "pulverising": "pulverizing", + "pummelled": "pummel", + "pummelling": "pummeled", + "pyjama": "pajama", + "pyjamas": "pajamas", + "pzazz": "pizzazz", + "quarrelled": "quarreled", + "quarrelling": "quarreling", + "radicalise": "radicalize", + "radicalised": "radicalized", + "radicalises": "radicalizes", + "radicalising": "radicalizing", + "rancour": "rancor", + "randomise": "randomize", + "randomised": "randomized", + "randomises": "randomizes", + "randomising": "randomizing", + "rationalisation": "rationalization", + "rationalisations": "rationalizations", + "rationalise": "rationalize", + "rationalised": "rationalized", + "rationalises": "rationalizes", + "rationalising": "rationalizing", + "ravelled": "raveled", + "ravelling": "raveling", + "realisable": "realizable", + "realisation": "realization", + "realisations": "realizations", + "realise": "realize", + "realised": "realized", + "realises": "realizes", + "realising": "realizing", + "recognisable": "recognizable", + "recognisably": "recognizably", + "recognisance": "recognizance", + "recognise": "recognize", + "recognised": "recognized", + "recognises": "recognizes", + "recognising": "recognizing", + "reconnoitre": "reconnoiter", + "reconnoitred": "reconnoitered", + "reconnoitres": "reconnoiters", + "reconnoitring": "reconnoitering", + "refuelled": "refueled", + "refuelling": "refueling", + "regularisation": "regularization", + "regularise": "regularize", + "regularised": "regularized", + "regularises": "regularizes", + "regularising": "regularizing", + "remodelled": "remodeled", + "remodelling": "remodeling", + "remould": "remold", + "remoulded": "remolded", + "remoulding": "remolding", + "remoulds": "remolds", + "reorganisation": "reorganization", + "reorganisations": "reorganizations", + "reorganise": "reorganize", + "reorganised": "reorganized", + "reorganises": "reorganizes", + "reorganising": "reorganizing", + "revelled": "reveled", + "reveller": "reveler", + "revellers": "revelers", + "revelling": "reveling", + "revitalise": "revitalize", + "revitalised": "revitalized", + "revitalises": "revitalizes", + "revitalising": "revitalizing", + "revolutionise": "revolutionize", + "revolutionised": "revolutionized", + "revolutionises": "revolutionizes", + "revolutionising": "revolutionizing", + "rhapsodise": "rhapsodize", + "rhapsodised": "rhapsodized", + "rhapsodises": "rhapsodizes", + "rhapsodising": "rhapsodizing", + "rigour": "rigor", + "rigours": "rigors", + "ritualised": "ritualized", + "rivalled": "rivaled", + "rivalling": "rivaling", + "romanticise": "romanticize", + "romanticised": "romanticized", + "romanticises": "romanticizes", + "romanticising": "romanticizing", + "rumour": "rumor", + "rumoured": "rumored", + "rumours": "rumors", + "sabre": "saber", + "sabres": "sabers", + "saltpetre": "saltpeter", + "sanitise": "sanitize", + "sanitised": "sanitized", + "sanitises": "sanitizes", + "sanitising": "sanitizing", + "satirise": "satirize", + "satirised": "satirized", + "satirises": "satirizes", + "satirising": "satirizing", + "saviour": "savior", + "saviours": "saviors", + "savour": "savor", + "savoured": "savored", + "savouries": "savories", + "savouring": "savoring", + "savours": "savors", + "savoury": "savory", + "scandalise": "scandalize", + "scandalised": "scandalized", + "scandalises": "scandalizes", + "scandalising": "scandalizing", + "sceptic": "skeptic", + "sceptical": "skeptical", + "sceptically": "skeptically", + "scepticism": "skepticism", + "sceptics": "skeptics", + "sceptre": "scepter", + "sceptres": "scepters", + "scrutinise": "scrutinize", + "scrutinised": "scrutinized", + "scrutinises": "scrutinizes", + "scrutinising": "scrutinizing", + "secularisation": "secularization", + "secularise": "secularize", + "secularised": "secularized", + "secularises": "secularizes", + "secularising": "secularizing", + "sensationalise": "sensationalize", + "sensationalised": "sensationalized", + "sensationalises": "sensationalizes", + "sensationalising": "sensationalizing", + "sensitise": "sensitize", + "sensitised": "sensitized", + "sensitises": "sensitizes", + "sensitising": "sensitizing", + "sentimentalise": "sentimentalize", + "sentimentalised": "sentimentalized", + "sentimentalises": "sentimentalizes", + "sentimentalising": "sentimentalizing", + "sepulchre": "sepulcher", + "sepulchres": "sepulchers", + "serialisation": "serialization", + "serialisations": "serializations", + "serialise": "serialize", + "serialised": "serialized", + "serialises": "serializes", + "serialising": "serializing", + "sermonise": "sermonize", + "sermonised": "sermonized", + "sermonises": "sermonizes", + "sermonising": "sermonizing", + "sheikh": "sheik", + "shovelled": "shoveled", + "shovelling": "shoveling", + "shrivelled": "shriveled", + "shrivelling": "shriveling", + "signalise": "signalize", + "signalised": "signalized", + "signalises": "signalizes", + "signalising": "signalizing", + "signalled": "signaled", + "signalling": "signaling", + "smoulder": "smolder", + "smouldered": "smoldered", + "smouldering": "smoldering", + "smoulders": "smolders", + "snivelled": "sniveled", + "snivelling": "sniveling", + "snorkelled": "snorkeled", + "snorkelling": "snorkeling", + "snowplough": "snowplow", + "snowploughs": "snowplow", + "socialisation": "socialization", + "socialise": "socialize", + "socialised": "socialized", + "socialises": "socializes", + "socialising": "socializing", + "sodomise": "sodomize", + "sodomised": "sodomized", + "sodomises": "sodomizes", + "sodomising": "sodomizing", + "solemnise": "solemnize", + "solemnised": "solemnized", + "solemnises": "solemnizes", + "solemnising": "solemnizing", + "sombre": "somber", + "specialisation": "specialization", + "specialisations": "specializations", + "specialise": "specialize", + "specialised": "specialized", + "specialises": "specializes", + "specialising": "specializing", + "spectre": "specter", + "spectres": "specters", + "spiralled": "spiraled", + "spiralling": "spiraling", + "splendour": "splendor", + "splendours": "splendors", + "squirrelled": "squirreled", + "squirrelling": "squirreling", + "stabilisation": "stabilization", + "stabilise": "stabilize", + "stabilised": "stabilized", + "stabiliser": "stabilizer", + "stabilisers": "stabilizers", + "stabilises": "stabilizes", + "stabilising": "stabilizing", + "standardisation": "standardization", + "standardise": "standardize", + "standardised": "standardized", + "standardises": "standardizes", + "standardising": "standardizing", + "stencilled": "stenciled", + "stencilling": "stenciling", + "sterilisation": "sterilization", + "sterilisations": "sterilizations", + "sterilise": "sterilize", + "sterilised": "sterilized", + "steriliser": "sterilizer", + "sterilisers": "sterilizers", + "sterilises": "sterilizes", + "sterilising": "sterilizing", + "stigmatisation": "stigmatization", + "stigmatise": "stigmatize", + "stigmatised": "stigmatized", + "stigmatises": "stigmatizes", + "stigmatising": "stigmatizing", + "storey": "story", + "storeys": "stories", + "subsidisation": "subsidization", + "subsidise": "subsidize", + "subsidised": "subsidized", + "subsidiser": "subsidizer", + "subsidisers": "subsidizers", + "subsidises": "subsidizes", + "subsidising": "subsidizing", + "succour": "succor", + "succoured": "succored", + "succouring": "succoring", + "succours": "succors", + "sulphate": "sulfate", + "sulphates": "sulfates", + "sulphide": "sulfide", + "sulphides": "sulfides", + "sulphur": "sulfur", + "sulphurous": "sulfurous", + "summarise": "summarize", + "summarised": "summarized", + "summarises": "summarizes", + "summarising": "summarizing", + "swivelled": "swiveled", + "swivelling": "swiveling", + "symbolise": "symbolize", + "symbolised": "symbolized", + "symbolises": "symbolizes", + "symbolising": "symbolizing", + "sympathise": "sympathize", + "sympathised": "sympathized", + "sympathiser": "sympathizer", + "sympathisers": "sympathizers", + "sympathises": "sympathizes", + "sympathising": "sympathizing", + "synchronisation": "synchronization", + "synchronise": "synchronize", + "synchronised": "synchronized", + "synchronises": "synchronizes", + "synchronising": "synchronizing", + "synthesise": "synthesize", + "synthesised": "synthesized", + "synthesiser": "synthesizer", + "synthesisers": "synthesizers", + "synthesises": "synthesizes", + "synthesising": "synthesizing", + "syphon": "siphon", + "syphoned": "siphoned", + "syphoning": "siphoning", + "syphons": "siphons", + "systematisation": "systematization", + "systematise": "systematize", + "systematised": "systematized", + "systematises": "systematizes", + "systematising": "systematizing", + "tantalise": "tantalize", + "tantalised": "tantalized", + "tantalises": "tantalizes", + "tantalising": "tantalizing", + "tantalisingly": "tantalizingly", + "tasselled": "tasseled", + "technicolour": "technicolor", + "temporise": "temporize", + "temporised": "temporized", + "temporises": "temporizes", + "temporising": "temporizing", + "tenderise": "tenderize", + "tenderised": "tenderized", + "tenderises": "tenderizes", + "tenderising": "tenderizing", + "terrorise": "terrorize", + "terrorised": "terrorized", + "terrorises": "terrorizes", + "terrorising": "terrorizing", + "theatre": "theater", + "theatregoer": "theatergoer", + "theatregoers": "theatergoers", + "theatres": "theaters", + "theorise": "theorize", + "theorised": "theorized", + "theorises": "theorizes", + "theorising": "theorizing", + "tonne": "ton", + "tonnes": "tons", + "towelled": "toweled", + "towelling": "toweling", + "toxaemia": "toxemia", + "tranquillise": "tranquilize", + "tranquillised": "tranquilized", + "tranquilliser": "tranquilizer", + "tranquillisers": "tranquilizers", + "tranquillises": "tranquilizes", + "tranquillising": "tranquilizing", + "tranquillity": "tranquility", + "tranquillize": "tranquilize", + "tranquillized": "tranquilized", + "tranquillizer": "tranquilizer", + "tranquillizers": "tranquilizers", + "tranquillizes": "tranquilizes", + "tranquillizing": "tranquilizing", + "tranquilly": "tranquility", + "transistorised": "transistorized", + "traumatise": "traumatize", + "traumatised": "traumatized", + "traumatises": "traumatizes", + "traumatising": "traumatizing", + "travelled": "traveled", + "traveller": "traveler", + "travellers": "travelers", + "travelling": "traveling", + "travelog": "travelogue", + "travelogs": "travelogues", + "trialled": "trialed", + "trialling": "trialing", + "tricolour": "tricolor", + "tricolours": "tricolors", + "trivialise": "trivialize", + "trivialised": "trivialized", + "trivialises": "trivializes", + "trivialising": "trivializing", + "tumour": "tumor", + "tumours": "tumors", + "tunnelled": "tunneled", + "tunnelling": "tunneling", + "tyrannise": "tyrannize", + "tyrannised": "tyrannized", + "tyrannises": "tyrannizes", + "tyrannising": "tyrannizing", + "tyre": "tire", + "tyres": "tires", + "unauthorised": "unauthorized", + "uncivilised": "uncivilized", + "underutilised": "underutilized", + "unequalled": "unequaled", + "unfavourable": "unfavorable", + "unfavourably": "unfavorably", + "unionisation": "unionization", + "unionise": "unionize", + "unionised": "unionized", + "unionises": "unionizes", + "unionising": "unionizing", + "unorganised": "unorganized", + "unravelled": "unraveled", + "unravelling": "unraveling", + "unrecognisable": "unrecognizable", + "unrecognised": "unrecognized", + "unrivalled": "unrivaled", + "unsavoury": "unsavory", + "untrammelled": "untrammeled", + "urbanisation": "urbanization", + "urbanise": "urbanize", + "urbanised": "urbanized", + "urbanises": "urbanizes", + "urbanising": "urbanizing", + "utilisable": "utilizable", + "utilisation": "utilization", + "utilise": "utilize", + "utilised": "utilized", + "utilises": "utilizes", + "utilising": "utilizing", + "valour": "valor", + "vandalise": "vandalize", + "vandalised": "vandalized", + "vandalises": "vandalizes", + "vandalising": "vandalizing", + "vaporisation": "vaporization", + "vaporise": "vaporize", + "vaporised": "vaporized", + "vaporises": "vaporizes", + "vaporising": "vaporizing", + "vapour": "vapor", + "vapours": "vapors", + "verbalise": "verbalize", + "verbalised": "verbalized", + "verbalises": "verbalizes", + "verbalising": "verbalizing", + "victimisation": "victimization", + "victimise": "victimize", + "victimised": "victimized", + "victimises": "victimizes", + "victimising": "victimizing", + "videodisc": "videodisk", + "videodiscs": "videodisks", + "vigour": "vigor", + "visualisation": "visualization", + "visualisations": "visualizations", + "visualise": "visualize", + "visualised": "visualized", + "visualises": "visualizes", + "visualising": "visualizing", + "vocalisation": "vocalization", + "vocalisations": "vocalizations", + "vocalise": "vocalize", + "vocalised": "vocalized", + "vocalises": "vocalizes", + "vocalising": "vocalizing", + "vulcanised": "vulcanized", + "vulgarisation": "vulgarization", + "vulgarise": "vulgarize", + "vulgarised": "vulgarized", + "vulgarises": "vulgarizes", + "vulgarising": "vulgarizing", + "waggon": "wagon", + "waggons": "wagons", + "watercolour": "watercolor", + "watercolours": "watercolors", + "weaselled": "weaseled", + "weaselling": "weaseling", + "westernisation": "westernization", + "westernise": "westernize", + "westernised": "westernized", + "westernises": "westernizes", + "westernising": "westernizing", + "womanise": "womanize", + "womanised": "womanized", + "womaniser": "womanizer", + "womanisers": "womanizers", + "womanises": "womanizes", + "womanising": "womanizing", + "woollen": "woolen", + "woollens": "woolens", + "woollies": "woolies", + "woolly": "wooly", + "worshipped": "worshiped", + "worshipping": "worshiping", + "worshipper": "worshiper", + "yodelled": "yodeled", + "yodelling": "yodeling", + "yoghourt": "yogurt", + "yoghourts": "yogurts", + "yoghurt": "yogurt", + "yoghurts": "yogurts", + "mhm": "hmm", + "mmm": "hmm" +} \ No newline at end of file diff --git a/speechmatics/metrics/normalizers/english.py b/speechmatics/metrics/normalizers/english.py new file mode 100644 index 0000000..d5c2bb4 --- /dev/null +++ b/speechmatics/metrics/normalizers/english.py @@ -0,0 +1,543 @@ +import json +import os +import re +from fractions import Fraction +from typing import Iterator, List, Match, Optional, Union + +from more_itertools import windowed + +from .basic import remove_symbols_and_diacritics + + +class EnglishNumberNormalizer: + """ + Convert any spelled-out numbers into arabic numbers, while handling: + + - remove any commas + - keep the suffixes such as: `1960s`, `274th`, `32nd`, etc. + - spell out currency symbols after the number. e.g. `$20 million` -> `20000000 dollars` + - spell out `one` and `ones` + - interpret successive single-digit numbers as nominal: `one oh one` -> `101` + """ + + def __init__(self): + super().__init__() + + self.zeros = {"o", "oh", "zero"} + self.ones = { + name: i + for i, name in enumerate( + [ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + ], + start=1, + ) + } + self.ones_plural = { + "sixes" if name == "six" else name + "s": (value, "s") + for name, value in self.ones.items() + } + self.ones_ordinal = { + "zeroth": (0, "th"), + "first": (1, "st"), + "second": (2, "nd"), + "third": (3, "rd"), + "fifth": (5, "th"), + "twelfth": (12, "th"), + **{ + name + ("h" if name.endswith("t") else "th"): (value, "th") + for name, value in self.ones.items() + if value > 3 and value != 5 and value != 12 + }, + } + self.ones_suffixed = {**self.ones_plural, **self.ones_ordinal} + + self.tens = { + "twenty": 20, + "thirty": 30, + "forty": 40, + "fifty": 50, + "sixty": 60, + "seventy": 70, + "eighty": 80, + "ninety": 90, + } + self.tens_plural = { + name.replace("y", "ies"): (value, "s") for name, value in self.tens.items() + } + self.tens_ordinal = { + name.replace("y", "ieth"): (value, "th") for name, value in self.tens.items() + } + self.tens_suffixed = {**self.tens_plural, **self.tens_ordinal} + + self.multipliers = { + "hundred": 100, + "thousand": 1_000, + "million": 1_000_000, + "billion": 1_000_000_000, + "trillion": 1_000_000_000_000, + "quadrillion": 1_000_000_000_000_000, + "quintillion": 1_000_000_000_000_000_000, + "sextillion": 1_000_000_000_000_000_000_000, + "septillion": 1_000_000_000_000_000_000_000_000, + "octillion": 1_000_000_000_000_000_000_000_000_000, + "nonillion": 1_000_000_000_000_000_000_000_000_000_000, + "decillion": 1_000_000_000_000_000_000_000_000_000_000_000, + } + self.multipliers_plural = { + name + "s": (value, "s") for name, value in self.multipliers.items() + } + self.multipliers_ordinal = { + name + "th": (value, "th") for name, value in self.multipliers.items() + } + self.multipliers_suffixed = {**self.multipliers_plural, **self.multipliers_ordinal} + self.decimals = {*self.ones, *self.tens, *self.zeros} + + self.preceding_prefixers = { + "minus": "-", + "negative": "-", + "plus": "+", + "positive": "+", + } + self.following_prefixers = { + "pound": "£", + "pounds": "£", + "euro": "€", + "euros": "€", + "dollar": "$", + "dollars": "$", + "cent": "¢", + "cents": "¢", + } + self.prefixes = set( + list(self.preceding_prefixers.values()) + list(self.following_prefixers.values()) + ) + self.suffixers = { + "per": {"cent": "%"}, + "percent": "%", + } + self.specials = {"and", "double", "triple", "point"} + + self.words = set( + [ + key + for mapping in [ + self.zeros, + self.ones, + self.ones_suffixed, + self.tens, + self.tens_suffixed, + self.multipliers, + self.multipliers_suffixed, + self.preceding_prefixers, + self.following_prefixers, + self.suffixers, + self.specials, + ] + for key in mapping + ] + ) + self.literal_words = {"one", "ones"} + + def process_words(self, words: List[str]) -> Iterator[str]: + prefix: Optional[str] = None + value: Optional[Union[str, int]] = None + skip = False + + def to_fraction(s: str): + try: + return Fraction(s) + except ValueError: + return None + + def output(result: Union[str, int]): + nonlocal prefix, value + result = str(result) + if prefix is not None: + result = prefix + result + value = None + prefix = None + return result + + if len(words) == 0: + return + + for prev, current, next in windowed([None] + words + [None], 3): + if skip: + skip = False + continue + + next_is_numeric = next is not None and re.match(r"^\d+(\.\d+)?$", next) + has_prefix = current[0] in self.prefixes + current_without_prefix = current[1:] if has_prefix else current + if re.match(r"^\d+(\.\d+)?$", current_without_prefix): + # arabic numbers (potentially with signs and fractions) + f = to_fraction(current_without_prefix) + assert f is not None + if value is not None: + if isinstance(value, str) and value.endswith("."): + # concatenate decimals / ip address components + value = str(value) + str(current) + continue + else: + yield output(value) + + prefix = current[0] if has_prefix else prefix + if f.denominator == 1: + value = f.numerator # store integers as int + else: + value = current_without_prefix + elif current not in self.words: + # non-numeric words + if value is not None: + yield output(value) + yield output(current) + elif current in self.zeros: + value = str(value or "") + "0" + elif current in self.ones: + ones = self.ones[current] + + if value is None: + value = ones + elif isinstance(value, str) or prev in self.ones: + if prev in self.tens and ones < 10: # replace the last zero with the digit + assert value[-1] == "0" + value = value[:-1] + str(ones) + else: + value = str(value) + str(ones) + elif ones < 10: + if value % 10 == 0: + value += ones + else: + value = str(value) + str(ones) + else: # eleven to nineteen + if value % 100 == 0: + value += ones + else: + value = str(value) + str(ones) + elif current in self.ones_suffixed: + # ordinal or cardinal; yield the number right away + ones, suffix = self.ones_suffixed[current] + if value is None: + yield output(str(ones) + suffix) + elif isinstance(value, str) or prev in self.ones: + if prev in self.tens and ones < 10: + assert value[-1] == "0" + yield output(value[:-1] + str(ones) + suffix) + else: + yield output(str(value) + str(ones) + suffix) + elif ones < 10: + if value % 10 == 0: + yield output(str(value + ones) + suffix) + else: + yield output(str(value) + str(ones) + suffix) + else: # eleven to nineteen + if value % 100 == 0: + yield output(str(value + ones) + suffix) + else: + yield output(str(value) + str(ones) + suffix) + value = None + elif current in self.tens: + tens = self.tens[current] + if value is None: + value = tens + elif isinstance(value, str): + value = str(value) + str(tens) + else: + if value % 100 == 0: + value += tens + else: + value = str(value) + str(tens) + elif current in self.tens_suffixed: + # ordinal or cardinal; yield the number right away + tens, suffix = self.tens_suffixed[current] + if value is None: + yield output(str(tens) + suffix) + elif isinstance(value, str): + yield output(str(value) + str(tens) + suffix) + else: + if value % 100 == 0: + yield output(str(value + tens) + suffix) + else: + yield output(str(value) + str(tens) + suffix) + elif current in self.multipliers: + multiplier = self.multipliers[current] + if value is None: + value = multiplier + elif isinstance(value, str) or value == 0: + f = to_fraction(value) + p = f * multiplier if f is not None else None + if f is not None and p.denominator == 1: + value = p.numerator + else: + yield output(value) + value = multiplier + else: + before = value // 1000 * 1000 + residual = value % 1000 + value = before + residual * multiplier + elif current in self.multipliers_suffixed: + multiplier, suffix = self.multipliers_suffixed[current] + if value is None: + yield output(str(multiplier) + suffix) + elif isinstance(value, str): + f = to_fraction(value) + p = f * multiplier if f is not None else None + if f is not None and p.denominator == 1: + yield output(str(p.numerator) + suffix) + else: + yield output(value) + yield output(str(multiplier) + suffix) + else: # int + before = value // 1000 * 1000 + residual = value % 1000 + value = before + residual * multiplier + yield output(str(value) + suffix) + value = None + elif current in self.preceding_prefixers: + # apply prefix (positive, minus, etc.) if it precedes a number + if value is not None: + yield output(value) + + if next in self.words or next_is_numeric: + prefix = self.preceding_prefixers[current] + else: + yield output(current) + elif current in self.following_prefixers: + # apply prefix (dollars, cents, etc.) only after a number + if value is not None: + prefix = self.following_prefixers[current] + yield output(value) + else: + yield output(current) + elif current in self.suffixers: + # apply suffix symbols (percent -> '%') + if value is not None: + suffix = self.suffixers[current] + if isinstance(suffix, dict): + if next in suffix: + yield output(str(value) + suffix[next]) + skip = True + else: + yield output(value) + yield output(current) + else: + yield output(str(value) + suffix) + else: + yield output(current) + elif current in self.specials: + if next not in self.words and not next_is_numeric: + # apply special handling only if the next word can be numeric + if value is not None: + yield output(value) + yield output(current) + elif current == "and": + # ignore "and" after hundreds, thousands, etc. + if prev not in self.multipliers: + if value is not None: + yield output(value) + yield output(current) + elif current == "double" or current == "triple": + if next in self.ones or next in self.zeros: + repeats = 2 if current == "double" else 3 + ones = self.ones.get(next, 0) + value = str(value or "") + str(ones) * repeats + skip = True + else: + if value is not None: + yield output(value) + yield output(current) + elif current == "point": + if next in self.decimals or next_is_numeric: + value = str(value or "") + "." + else: + # should all have been covered at this point + raise ValueError(f"Unexpected token: {current}") + else: + # all should have been covered at this point + raise ValueError(f"Unexpected token: {current}") + + if value is not None: + yield output(value) + + def preprocess(self, s: str): + # replace " and a half" with " point five" + results = [] + + segments = re.split(r"\band\s+a\s+half\b", s) + for i, segment in enumerate(segments): + if len(segment.strip()) == 0: + continue + if i == len(segments) - 1: + results.append(segment) + else: + results.append(segment) + last_word = segment.rsplit(maxsplit=2)[-1] + if last_word in self.decimals or last_word in self.multipliers: + results.append("point five") + else: + results.append("and a half") + + s = " ".join(results) + + # put a space at number/letter boundary + s = re.sub(r"([a-z])([0-9])", r"\1 \2", s) + s = re.sub(r"([0-9])([a-z])", r"\1 \2", s) + + # but remove spaces which could be a suffix + s = re.sub(r"([0-9])\s+(st|nd|rd|th|s)\b", r"\1\2", s) + + return s + + def postprocess(self, s: str): + def combine_cents(m: Match): + try: + currency = m.group(1) + integer = m.group(2) + cents = int(m.group(3)) + return f"{currency}{integer}.{cents:02d}" + except ValueError: + return m.string + + def extract_cents(m: Match): + try: + return f"¢{int(m.group(1))}" + except ValueError: + return m.string + + # apply currency postprocessing; "$2 and ¢7" -> "$2.07" + s = re.sub(r"([€£$])([0-9]+) (?:and )?¢([0-9]{1,2})\b", combine_cents, s) + s = re.sub(r"[€£$]0.([0-9]{1,2})\b", extract_cents, s) + + # write "one(s)" instead of "1(s)", just for the readability + s = re.sub(r"\b1(s?)\b", r"one\1", s) + + return s + + def __call__(self, s: str): + s = self.preprocess(s) + s = " ".join(word for word in self.process_words(s.split()) if word is not None) + s = self.postprocess(s) + + return s + + +class EnglishSpellingNormalizer: + """ + Applies British-American spelling mappings as listed in [1]. + + [1] https://www.tysto.com/uk-us-spelling-list.html + """ + + def __init__(self): + mapping_path = os.path.join(os.path.dirname(__file__), "english.json") + self.mapping = json.load(open(mapping_path)) + + def __call__(self, s: str): + return " ".join(self.mapping.get(word, word) for word in s.split()) + + +class EnglishTextNormalizer: + def __init__(self): + self.ignore_patterns = r"\b(hmm|mm|mhm|mmm|uh|um)\b" + self.replacers = { + # common contractions + r"\bwon't\b": "will not", + r"\bcan't\b": "can not", + r"\blet's\b": "let us", + r"\bain't\b": "aint", + r"\by'all\b": "you all", + r"\bwanna\b": "want to", + r"\bgotta\b": "got to", + r"\bgonna\b": "going to", + r"\bi'ma\b": "i am going to", + r"\bimma\b": "i am going to", + r"\bwoulda\b": "would have", + r"\bcoulda\b": "could have", + r"\bshoulda\b": "should have", + r"\bma'am\b": "madam", + # contractions in titles/prefixes + r"\bmr\b": "mister ", + r"\bmrs\b": "missus ", + r"\bst\b": "saint ", + r"\bdr\b": "doctor ", + r"\bprof\b": "professor ", + r"\bcapt\b": "captain ", + r"\bgov\b": "governor ", + r"\bald\b": "alderman ", + r"\bgen\b": "general ", + r"\bsen\b": "senator ", + r"\brep\b": "representative ", + r"\bpres\b": "president ", + r"\brev\b": "reverend ", + r"\bhon\b": "honorable ", + r"\basst\b": "assistant ", + r"\bassoc\b": "associate ", + r"\blt\b": "lieutenant ", + r"\bcol\b": "colonel ", + r"\bjr\b": "junior ", + r"\bsr\b": "senior ", + r"\besq\b": "esquire ", + # prefect tenses, ideally it should be any past participles, but it's harder.. + r"'d been\b": " had been", + r"'s been\b": " has been", + r"'d gone\b": " had gone", + r"'s gone\b": " has gone", + r"'d done\b": " had done", # "'s done" is ambiguous + r"'s got\b": " has got", + # general contractions + r"n't\b": " not", + r"'re\b": " are", + r"'s\b": " is", + r"'d\b": " would", + r"'ll\b": " will", + r"'t\b": " not", + r"'ve\b": " have", + r"'m\b": " am", + } + self.standardize_numbers = EnglishNumberNormalizer() + self.standardize_spellings = EnglishSpellingNormalizer() + + def __call__(self, s: str): + s = s.lower() + + s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) # remove words between brackets + s = re.sub(r"\(([^)]+?)\)", "", s) # remove words between parenthesis + s = re.sub(self.ignore_patterns, "", s) + s = re.sub(r"\s+'", "'", s) # standardize when there's a space before an apostrophe + + for pattern, replacement in self.replacers.items(): + s = re.sub(pattern, replacement, s) + + s = re.sub(r"(\d),(\d)", r"\1\2", s) # remove commas between digits + s = re.sub(r"\.([^0-9]|$)", r" \1", s) # remove periods not followed by numbers + s = remove_symbols_and_diacritics(s, keep=".%$¢€£") # keep some symbols for numerics + + s = self.standardize_numbers(s) + s = self.standardize_spellings(s) + + # now remove prefix/suffix symbols that are not preceded/followed by numbers + s = re.sub(r"[.$¢€£]([^0-9])", r" \1", s) + s = re.sub(r"([^0-9])%", r"\1 ", s) + + s = re.sub(r"\s+", " ", s) # replace any successive whitespace characters with a space + + return s diff --git a/speechmatics/metrics/wer.py b/speechmatics/metrics/wer.py new file mode 100644 index 0000000..66fdf2c --- /dev/null +++ b/speechmatics/metrics/wer.py @@ -0,0 +1,319 @@ +""" +Simple script to run WER analysis using Whisper normalisers +Prints results to terminal +""" +import difflib +import csv +from typing import Any, Tuple +from collections import Counter +from argparse import ArgumentParser +from jiwer import compute_measures, cer +from normalizers import BasicTextNormalizer, EnglishTextNormalizer + + +class DiffColours: + """ + Class to define the colours used in a diff + See here: https://tforgione.fr/posts/ansi-escape-codes/ + """ + + green = "\x1b[38;5;16;48;5;2m" + red = "\x1b[38;5;16;48;5;1m" + yellow = "\x1b[0;30;43m" + endcolour = "\x1b[0m" + + +def load_file(path) -> str: + """ + Returns a string containing the contents of a file, given the file path + """ + with open(path, "r", encoding="utf-8") as input_path: + return input_path.read() + + +def read_dbl(path) -> list[str]: + """ + Returns a list of file path, given an input DBL file path + """ + with open(path, "r", encoding="utf-8") as input_path: + return input_path.readlines() + + +def diff_strings( + ref: list, hyp: list, join_token: str = " " +) -> Tuple[list, list, list, list]: + """ + Show a colourised diff between two input strings. + + Args: + ref (list): a list of tokens from the reference transcript. + hyp (list): a list of tokens from the hypothesis transcript. + join_token (str): the character between input tokens. Defaults to a single space. + + Returns: + A 4-tuple with the following: + + output (list): list of colourised transcript segments using ANSI Escape codes + insertions (list): list of inserted segments in the hypothesis transcript + deletions (list): list of deleted segments from the reference transcript + substitutions (list): list of substituted segments from the reference to the hypothesis + + """ + output = [] + insertions = [] + deletions = [] + substitutions = [] + matcher = difflib.SequenceMatcher(None, ref, hyp) + + for opcode, a0, a1, b0, b1 in matcher.get_opcodes(): + + if opcode == "equal": + output.append(join_token.join(ref[a0:a1])) + + if opcode == "insert": + segment = join_token.join(hyp[b0:b1]) + output.append(f"{DiffColours.green}{segment}{DiffColours.endcolour}") + insertions.append(segment) + + if opcode == "delete": + segment = join_token.join(ref[a0:a1]) + output.append(f"{DiffColours.red}{segment}{DiffColours.endcolour}") + deletions.append(segment) + + if opcode == "replace": + ref_segment = join_token.join(ref[a0:a1]) + hyp_segment = join_token.join(hyp[b0:b1]) + output.append( + f"{DiffColours.yellow}{ref_segment} -> {hyp_segment}{DiffColours.endcolour}" + ) + substitutions.append(f"{ref_segment} -> {hyp_segment}") + + return output, insertions, deletions, substitutions + + +def count_errors(errors_list: list) -> list[tuple[Any, int]]: + """ + Use Counter.most_common() to list errors by the number of times they occur + + Args: + errors_list (list): a list of errors to be enumerated + + Returns: + list of tuples in the form (error, number of occurances) + """ + return Counter(errors_list).most_common() + + +def print_colourised_diff(diff: str) -> None: + """ + Prints the colourised diff and error key + + Arguments: + diff (str): the colourised diff as a single string + """ + print("DIFF", end="\n\n") + print(f"{DiffColours.green}INSERTION{DiffColours.endcolour}") + print(f"{DiffColours.red}DELETION{DiffColours.endcolour}") + print(f"{DiffColours.yellow}SUBSTITUTION{DiffColours.endcolour}", end="\n\n") + print(diff, end="\n\n") + + +def print_errors_chronologically( + insertions: list, deletions: list, replacements: list +) -> None: + """ + Print the errors as they appear in the transcript. + + Args: + insertions (list): list of inserted segments in the hypothesis transcript + deletions (list): list of deleted segments from the reference transcript + substitutions (list): list of substituted segments from the reference to the hypothesis + """ + if len(insertions) > 0: + print(f"{DiffColours.green}INSERTIONS:{DiffColours.endcolour}") + for example in insertions: + print(f"'{example}'", end="\n") + + if len(deletions) > 0: + print(f"{DiffColours.red}DELETIONS:{DiffColours.endcolour}") + for example in deletions: + print(f"'{example}'", end="\n") + + if len(replacements) > 0: + print( + f"{DiffColours.yellow}SUBSTITUTIONS: (REFERENCE -> HYPOTHESIS):{DiffColours.endcolour}", + end="\n", + ) + for example in replacements: + print(f"'{example}'", end="\n") + + print("\n\n") + + +def print_errors_by_prevelance( + insertions: list, deletions: list, replacements: list +) -> None: + """ + Print the errors and the number of times they occur, in order of most -> least + + Args: + insertions (list): list of inserted segments in the hypothesis transcript + deletions (list): list of deleted segments from the reference transcript + substitutions (list): list of substituted segments from the reference to the hypothesis + """ + print(f"{DiffColours.green}INSERTIONS:{DiffColours.endcolour}") + for error, count in count_errors(insertions): + print(f"'{error}': {count}") + + print(f"{DiffColours.red}DELETIONS:{DiffColours.endcolour}") + for error, count in count_errors(deletions): + print(f"'{error}': {count}") + + print( + f"{DiffColours.yellow}SUBSTITUTIONS: (REFERENCE -> HYPOTHESIS):{DiffColours.endcolour}" + ) + for error, count in count_errors(replacements): + print(f"'{error}': {count}") + + print("\n\n") + + +def is_supported(file_name: str) -> bool: + "Takes input file name, checks if file is valid" + return file_name.endswith((".dbl", ".txt")) + + +def main(): + """ + Calls argparse to make a command line utility + """ + + parser = ArgumentParser() + parser.add_argument( + "--non-en", help="Indicate the language is NOT english", action="store_true" + ) + parser.add_argument( + "--show-normalised", help="Show the normalised transcipts", action="store_true" + ) + parser.add_argument( + "--diff", + help="Show a colourised diff between normalised transcripts", + action="store_true", + ) + parser.add_argument( + "--common-errors", help="Show common misrecognitions", action="store_true" + ) + parser.add_argument( + "--cer", + help=""" + Compute Character Error Rate instead of Word Error Rate. + Spaces are considered as characters here. + """, + action="store_true", + ) + parser.add_argument("--csv", help="Write the results to a CSV", action="store_true") + parser.add_argument("ref_path", help="Path to the reference transcript", type=str) + parser.add_argument("hyp_path", help="Path to the hypothesis transcript", type=str) + args = vars(parser.parse_args()) + print(args) + + normaliser = BasicTextNormalizer() if args["non_en"] else EnglishTextNormalizer() + + if ( + is_supported(args["ref_path"]) is not True + or is_supported(args["hyp_path"]) is not True + ): + raise ValueError("Unsupported file type, files must be .dbl or .txt files") + + if args["ref_path"].endswith(".dbl") and args["ref_path"].endswith(".dbl"): + ref_files = read_dbl(args["ref_path"]) + hyp_files = read_dbl(args["hyp_path"]) + assert len(ref_files) == len( + hyp_files + ), "Number of ref and hyp files should be the same" + + if args["ref_path"].endswith(".txt") and args["ref_path"].endswith(".txt"): + ref_files = [args["ref_path"]] + hyp_files = [args["hyp_path"]] + + results = [] + + for ref, hyp in zip(ref_files, hyp_files): + + raw_ref = load_file(ref.strip()) + raw_hyp = load_file(hyp.strip()) + norm_ref = normaliser(raw_ref) + norm_hyp = normaliser(raw_hyp) + + if args["cer"] is True: + stats = cer(norm_ref, norm_hyp, return_dict=True) + stats["reference length"] = len(list(norm_ref)) + stats["accuracy"] = 1 - stats["cer"] + diff, insertions, deletions, replacements = diff_strings( + list(norm_ref), list(norm_hyp), join_token="" + ) + + else: + stats = compute_measures(norm_ref, norm_hyp) + stats["reference length"] = len(norm_ref.split()) + stats["accuracy"] = 1 - stats["wer"] + diff, insertions, deletions, replacements = diff_strings( + norm_ref.split(), norm_hyp.split(), join_token=" " + ) + + stats["file name"] = hyp + + if args["show_normalised"] is True: + print("NORMALISED REFERENCE:", norm_ref, sep="\n\n", end="\n\n") + print("NORMALISED HYPOTHESIS:", norm_hyp, sep="\n\n", end="\n\n") + + if args["diff"] is True: + diff = "".join(diff) if args["cer"] is True else " ".join(diff) + print_colourised_diff(diff) + + if args["common_errors"] is True: + print_errors_by_prevelance(insertions, deletions, replacements) + + if args["common_errors"] is not True and args["diff"] is True: + print_errors_chronologically(insertions, deletions, replacements) + + for metric, val in stats.items(): + print(f"{metric}: {val}") + + results.append(stats) + + if args["csv"] is True: + + if args["cer"] is True: + fields = [ + "file name", + "cer", + "accuracy", + "insertions", + "deletions", + "substitutions", + "reference length", + ] + + else: + fields = [ + "file name", + "wer", + "accuracy", + "insertions", + "deletions", + "substitutions", + "reference length", + ] + + with open("results.csv", "w", encoding="utf-8") as results_csv: + writer = csv.DictWriter( + results_csv, fieldnames=fields, extrasaction="ignore" + ) + writer.writeheader() + for row in results: + writer.writerow(row) + + +if __name__ == "__main__": + main() From 6ccad69acfe420013f27cc0be9503e05bfd868fc Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Mon, 13 Mar 2023 16:54:48 +0000 Subject: [PATCH 02/40] Init commit --- speechmatics/metrics/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 speechmatics/metrics/__init__.py diff --git a/speechmatics/metrics/__init__.py b/speechmatics/metrics/__init__.py new file mode 100644 index 0000000..e69de29 From 880640cc3892e50c795f6d2d25996f09a00f27b2 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Mon, 13 Mar 2023 17:38:47 +0000 Subject: [PATCH 03/40] Fix relative imports --- speechmatics/metrics/wer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/speechmatics/metrics/wer.py b/speechmatics/metrics/wer.py index 66fdf2c..229190e 100644 --- a/speechmatics/metrics/wer.py +++ b/speechmatics/metrics/wer.py @@ -8,7 +8,7 @@ from collections import Counter from argparse import ArgumentParser from jiwer import compute_measures, cer -from normalizers import BasicTextNormalizer, EnglishTextNormalizer +from speechmatics.metrics.normalizers import BasicTextNormalizer, EnglishTextNormalizer class DiffColours: From 481c0e09306eac7db849d34964d5b205a38146f4 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Tue, 14 Mar 2023 10:52:36 +0000 Subject: [PATCH 04/40] Format with black --- speechmatics/metrics/normalizers/english.py | 57 +++++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/speechmatics/metrics/normalizers/english.py b/speechmatics/metrics/normalizers/english.py index d5c2bb4..f3ba8fa 100644 --- a/speechmatics/metrics/normalizers/english.py +++ b/speechmatics/metrics/normalizers/english.py @@ -84,7 +84,8 @@ def __init__(self): name.replace("y", "ies"): (value, "s") for name, value in self.tens.items() } self.tens_ordinal = { - name.replace("y", "ieth"): (value, "th") for name, value in self.tens.items() + name.replace("y", "ieth"): (value, "th") + for name, value in self.tens.items() } self.tens_suffixed = {**self.tens_plural, **self.tens_ordinal} @@ -108,7 +109,10 @@ def __init__(self): self.multipliers_ordinal = { name + "th": (value, "th") for name, value in self.multipliers.items() } - self.multipliers_suffixed = {**self.multipliers_plural, **self.multipliers_ordinal} + self.multipliers_suffixed = { + **self.multipliers_plural, + **self.multipliers_ordinal, + } self.decimals = {*self.ones, *self.tens, *self.zeros} self.preceding_prefixers = { @@ -128,7 +132,8 @@ def __init__(self): "cents": "¢", } self.prefixes = set( - list(self.preceding_prefixers.values()) + list(self.following_prefixers.values()) + list(self.preceding_prefixers.values()) + + list(self.following_prefixers.values()) ) self.suffixers = { "per": {"cent": "%"}, @@ -218,7 +223,9 @@ def output(result: Union[str, int]): if value is None: value = ones elif isinstance(value, str) or prev in self.ones: - if prev in self.tens and ones < 10: # replace the last zero with the digit + if ( + prev in self.tens and ones < 10 + ): # replace the last zero with the digit assert value[-1] == "0" value = value[:-1] + str(ones) else: @@ -369,16 +376,23 @@ def output(result: Union[str, int]): if next in self.decimals or next_is_numeric: value = str(value or "") + "." else: - # should all have been covered at this point raise ValueError(f"Unexpected token: {current}") else: - # all should have been covered at this point raise ValueError(f"Unexpected token: {current}") if value is not None: yield output(value) def preprocess(self, s: str): + """ + Function standardises spacing between entities before processing + + Args: + s (str): The string to be preprocessed + + Returns: + s (str): the preprocessed stringm, with entities standardised + """ # replace " and a half" with " point five" results = [] @@ -398,11 +412,11 @@ def preprocess(self, s: str): s = " ".join(results) - # put a space at number/letter boundary + # put a space at number/letter boundary. e.g., AA00 AAA -> AA 00 AA s = re.sub(r"([a-z])([0-9])", r"\1 \2", s) s = re.sub(r"([0-9])([a-z])", r"\1 \2", s) - # but remove spaces which could be a suffix + # but remove spaces which could be a suffix. e.g., 21 st -> 21st s = re.sub(r"([0-9])\s+(st|nd|rd|th|s)\b", r"\1\2", s) return s @@ -457,9 +471,10 @@ def __call__(self, s: str): class EnglishTextNormalizer: def __init__(self): + # hesitations to be removed self.ignore_patterns = r"\b(hmm|mm|mhm|mmm|uh|um)\b" self.replacers = { - # common contractions + # expand common contractions r"\bwon't\b": "will not", r"\bcan't\b": "can not", r"\blet's\b": "let us", @@ -474,7 +489,7 @@ def __init__(self): r"\bcoulda\b": "could have", r"\bshoulda\b": "should have", r"\bma'am\b": "madam", - # contractions in titles/prefixes + # expand contracted titles/prefixes r"\bmr\b": "mister ", r"\bmrs\b": "missus ", r"\bst\b": "saint ", @@ -519,17 +534,24 @@ def __init__(self): def __call__(self, s: str): s = s.lower() - s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) # remove words between brackets - s = re.sub(r"\(([^)]+?)\)", "", s) # remove words between parenthesis + # remove words between square / rounded brackets + s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) + s = re.sub(r"\(([^)]+?)\)", "", s) s = re.sub(self.ignore_patterns, "", s) - s = re.sub(r"\s+'", "'", s) # standardize when there's a space before an apostrophe + # standardize when there's a space before an apostrophe + s = re.sub(r"\s+'", "'", s) + + # expand contractions using mapping for pattern, replacement in self.replacers.items(): s = re.sub(pattern, replacement, s) - s = re.sub(r"(\d),(\d)", r"\1\2", s) # remove commas between digits - s = re.sub(r"\.([^0-9]|$)", r" \1", s) # remove periods not followed by numbers - s = remove_symbols_and_diacritics(s, keep=".%$¢€£") # keep some symbols for numerics + # remove commas between digits and remove full stops not followed by digits + s = re.sub(r"(\d),(\d)", r"\1\2", s) + s = re.sub(r"\.([^0-9]|$)", r" \1", s) + + # keep some symbols for numerics + s = remove_symbols_and_diacritics(s, keep=".%$¢€£") s = self.standardize_numbers(s) s = self.standardize_spellings(s) @@ -538,6 +560,7 @@ def __call__(self, s: str): s = re.sub(r"[.$¢€£]([^0-9])", r" \1", s) s = re.sub(r"([^0-9])%", r"\1 ", s) - s = re.sub(r"\s+", " ", s) # replace any successive whitespace characters with a space + # replace any successive whitespace characters with a space + s = re.sub(r"\s+", " ", s) return s From 3b45f5268d73c579a808b5d5351b051b416623e4 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 10:56:44 +0000 Subject: [PATCH 05/40] Remove redundant parameters --- .pylintrc | 82 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 80 deletions(-) diff --git a/.pylintrc b/.pylintrc index 91ff35a..3b85a40 100644 --- a/.pylintrc +++ b/.pylintrc @@ -64,86 +64,14 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, +disable=raw-checker-failed, bad-inline-option, locally-disabled, - locally-enabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape, missing-docstring, no-value-for-parameter @@ -329,13 +257,6 @@ max-line-length=120 # Maximum number of lines in a module. max-module-lines=1000 -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no @@ -422,6 +343,7 @@ good-names=i, k, ex, Run, + s, _ # Include a hint for the correct naming format with invalid-name. From 890725de02ecfc454a9d63485cd2b25bf3789626 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 10:58:03 +0000 Subject: [PATCH 06/40] Refactor differ class --- speechmatics/metrics/wer.py | 396 ++++++++++++++++++------------------ 1 file changed, 197 insertions(+), 199 deletions(-) diff --git a/speechmatics/metrics/wer.py b/speechmatics/metrics/wer.py index 229190e..f82d5b5 100644 --- a/speechmatics/metrics/wer.py +++ b/speechmatics/metrics/wer.py @@ -11,19 +11,7 @@ from speechmatics.metrics.normalizers import BasicTextNormalizer, EnglishTextNormalizer -class DiffColours: - """ - Class to define the colours used in a diff - See here: https://tforgione.fr/posts/ansi-escape-codes/ - """ - - green = "\x1b[38;5;16;48;5;2m" - red = "\x1b[38;5;16;48;5;1m" - yellow = "\x1b[0;30;43m" - endcolour = "\x1b[0m" - - -def load_file(path) -> str: +def load_file(path: str) -> str: """ Returns a string containing the contents of a file, given the file path """ @@ -31,7 +19,7 @@ def load_file(path) -> str: return input_path.read() -def read_dbl(path) -> list[str]: +def read_dbl(path: str) -> list[str]: """ Returns a list of file path, given an input DBL file path """ @@ -39,56 +27,119 @@ def read_dbl(path) -> list[str]: return input_path.readlines() -def diff_strings( - ref: list, hyp: list, join_token: str = " " -) -> Tuple[list, list, list, list]: - """ - Show a colourised diff between two input strings. - - Args: - ref (list): a list of tokens from the reference transcript. - hyp (list): a list of tokens from the hypothesis transcript. - join_token (str): the character between input tokens. Defaults to a single space. - - Returns: - A 4-tuple with the following: - - output (list): list of colourised transcript segments using ANSI Escape codes - insertions (list): list of inserted segments in the hypothesis transcript - deletions (list): list of deleted segments from the reference transcript - substitutions (list): list of substituted segments from the reference to the hypothesis - - """ - output = [] - insertions = [] - deletions = [] - substitutions = [] - matcher = difflib.SequenceMatcher(None, ref, hyp) - - for opcode, a0, a1, b0, b1 in matcher.get_opcodes(): - - if opcode == "equal": - output.append(join_token.join(ref[a0:a1])) - - if opcode == "insert": - segment = join_token.join(hyp[b0:b1]) - output.append(f"{DiffColours.green}{segment}{DiffColours.endcolour}") - insertions.append(segment) - - if opcode == "delete": - segment = join_token.join(ref[a0:a1]) - output.append(f"{DiffColours.red}{segment}{DiffColours.endcolour}") - deletions.append(segment) - - if opcode == "replace": - ref_segment = join_token.join(ref[a0:a1]) - hyp_segment = join_token.join(hyp[b0:b1]) - output.append( - f"{DiffColours.yellow}{ref_segment} -> {hyp_segment}{DiffColours.endcolour}" - ) - substitutions.append(f"{ref_segment} -> {hyp_segment}") - - return output, insertions, deletions, substitutions +class TranscriptDiff(difflib.SequenceMatcher): + def __init__(self, ref: list, hyp: list, join_token=" "): + super().__init__(None, ref, hyp) + + self.endcolour = "\x1b[0m" + self.join_token = join_token + + self.errors: dict[str, list] = { + "insertions": [], + "deletions": [], + "substitutions": [], + } + + self.colour_mapping = { + "INSERTION": "\x1b[38;5;16;48;5;2m", + "DELETION": "\x1b[38;5;16;48;5;1m", + "SUBSTITUTION": "\x1b[0;30;43m", + } + self.ref = ref + self.hyp = hyp + self.diff = self.join_token.join(self.process_diff()) + + def _colourise_segment(self, transcript_segment: str, colour) -> str: + """ + Return a transcript with the ANSI escape codes attached either side + See here: https://tforgione.fr/posts/ansi-escape-codes/ + """ + return f"{colour}{transcript_segment}{self.endcolour}" + + def process_diff(self) -> list: + """ + Populates the error dict and returns a colourised diff between the transcripts + + Args: + ref (list): the reference transcript + hyp (list): the hypothesis transcript + + Returns: + diff (list): a colourised diff between the transcripts as a list of segments + """ + + diff = [] + for opcode, ref_i, ref_j, hyp_i, hyp_j in self.get_opcodes(): + + ref_segment = self.join_token.join(self.ref[ref_i:ref_j]) + hyp_segment = self.join_token.join(self.hyp[hyp_i:hyp_j]) + + if opcode == "equal": + diff.append(ref_segment) + + if opcode == "insert": + diff.append( + self._colourise_segment( + hyp_segment, self.colour_mapping["INSERTION"] + ) + ) + self.errors["insertions"].append(hyp_segment) + + if opcode == "delete": + diff.append( + self._colourise_segment( + ref_segment, self.colour_mapping["DELETION"] + ) + ) + self.errors["deletions"].append(ref_segment) + + if opcode == "replace": + diff.append( + self._colourise_segment( + f"{ref_segment} -> {hyp_segment}", + self.colour_mapping["SUBSTITUTION"], + ) + ) + self.errors["substitutions"].append(f"{ref_segment} -> {hyp_segment}") + + return diff + + def print_colourised_diff(self) -> None: + "Prints the colourised diff and error key" + print("DIFF", end="\n\n") + for error_type, colour in self.colour_mapping.items(): + print(self._colourise_segment(error_type, colour)) + print(self.diff, end="\n\n") + + def _print_errors_for_type(self, error_type: str, errors: list) -> None: + """ + Prints colourised key for error type and then prints all errors in list + + Args: + error_type (str): One of INSERTION, DELETION or SUBSTITUTION + errors (list): Contains a list of errorneous transcript segments + + Raises: + AssertionError if error_type is not one of INSERTION, DELETION or SUBSTITUTION + """ + assert error_type in ["INSERTION", "DELETION", "SUBSTITUTION"] + + if len(errors) > 0: + return None + + print(self._colourise_segment(error_type, self.colour_mapping[error_type])) + for error in errors: + print(error, end="\n") + + return None + + def print_errors_by_type(self) -> None: + """ + Iterates over each type of error and prints all examples + """ + error_types = ["INSERTION", "DELETION", "SUBSTITUTION"] + for error_type, list_of_errors in zip(error_types, self.errors.values()): + self._print_errors_for_type(error_type, list_of_errors) def count_errors(errors_list: list) -> list[tuple[Any, int]]: @@ -104,83 +155,88 @@ def count_errors(errors_list: list) -> list[tuple[Any, int]]: return Counter(errors_list).most_common() -def print_colourised_diff(diff: str) -> None: - """ - Prints the colourised diff and error key - - Arguments: - diff (str): the colourised diff as a single string - """ - print("DIFF", end="\n\n") - print(f"{DiffColours.green}INSERTION{DiffColours.endcolour}") - print(f"{DiffColours.red}DELETION{DiffColours.endcolour}") - print(f"{DiffColours.yellow}SUBSTITUTION{DiffColours.endcolour}", end="\n\n") - print(diff, end="\n\n") +def is_supported(file_name: str) -> bool: + "Takes input file name, checks if file is valid" + return file_name.endswith((".dbl", ".txt")) -def print_errors_chronologically( - insertions: list, deletions: list, replacements: list -) -> None: +def run_cer(ref: str, hyp: str) -> Tuple[TranscriptDiff, dict[str, Any]]: """ - Print the errors as they appear in the transcript. + Run CER for input reference and hypothesis transcripts Args: - insertions (list): list of inserted segments in the hypothesis transcript - deletions (list): list of deleted segments from the reference transcript - substitutions (list): list of substituted segments from the reference to the hypothesis + ref (str): reference transcript + hyp (str): hypothesis transcript + + Returns: + differ (dict): instance of the TranscriptDiff class with the error dict populated + stats (dict): a dictionary containing the CER and other stats """ - if len(insertions) > 0: - print(f"{DiffColours.green}INSERTIONS:{DiffColours.endcolour}") - for example in insertions: - print(f"'{example}'", end="\n") - - if len(deletions) > 0: - print(f"{DiffColours.red}DELETIONS:{DiffColours.endcolour}") - for example in deletions: - print(f"'{example}'", end="\n") - - if len(replacements) > 0: - print( - f"{DiffColours.yellow}SUBSTITUTIONS: (REFERENCE -> HYPOTHESIS):{DiffColours.endcolour}", - end="\n", - ) - for example in replacements: - print(f"'{example}'", end="\n") - - print("\n\n") - - -def print_errors_by_prevelance( - insertions: list, deletions: list, replacements: list -) -> None: + differ = TranscriptDiff(list(ref), list(hyp), join_token="") + stats = cer(ref, hyp, return_dict=True) + stats["reference length"] = len(list(ref)) + stats["accuracy"] = 1 - stats["cer"] + return differ, stats + + +def run_wer(ref: str, hyp: str) -> Tuple[TranscriptDiff, dict[str, Any]]: """ - Print the errors and the number of times they occur, in order of most -> least + Run WER for a single input reference and hypothesis transcript Args: - insertions (list): list of inserted segments in the hypothesis transcript - deletions (list): list of deleted segments from the reference transcript - substitutions (list): list of substituted segments from the reference to the hypothesis + ref (str): reference transcript + hyp (str): hypothesis transcript + + Returns: + differ (dict): instance of the TranscriptDiff class with the error dict populated + stats (dict): a dictionary containing the WER and other stats """ - print(f"{DiffColours.green}INSERTIONS:{DiffColours.endcolour}") - for error, count in count_errors(insertions): - print(f"'{error}': {count}") + differ = TranscriptDiff(ref.split(), hyp.split(), join_token=" ") + stats = compute_measures(ref, hyp) + stats["reference length"] = len(ref.split()) + stats["accuracy"] = 1 - stats["wer"] + return differ, stats - print(f"{DiffColours.red}DELETIONS:{DiffColours.endcolour}") - for error, count in count_errors(deletions): - print(f"'{error}': {count}") - print( - f"{DiffColours.yellow}SUBSTITUTIONS: (REFERENCE -> HYPOTHESIS):{DiffColours.endcolour}" - ) - for error, count in count_errors(replacements): - print(f"'{error}': {count}") +def generate_csv(results, using_cer=False): + """ + Writes results to a csv named 'results.csv' + """ + fields = [ + "file name", + "wer", + "accuracy", + "insertions", + "deletions", + "substitutions", + "reference length", + ] + + if using_cer is True: + fields[1] = "cer" + + with open("results.csv", "w", encoding="utf-8") as results_csv: + writer = csv.DictWriter(results_csv, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + for row in results: + writer.writerow(row) + + +def check_paths(ref_path, hyp_path) -> Tuple[list[str], list[str]]: + """ + Returns lists of ref and hyp file paths given input paths - print("\n\n") + Raises: + AssertionError: if input paths do not have same extension + """ + assert is_supported(ref_path) and is_supported(hyp_path) + if ref_path.endswith(".txt") and hyp_path.endswith(".txt"): + return [ref_path], [hyp_path] + if ref_path.endswith(".dbl") and hyp_path.endswith(".dbl"): + return read_dbl(ref_path), read_dbl(hyp_path) -def is_supported(file_name: str) -> bool: - "Takes input file name, checks if file is valid" - return file_name.endswith((".dbl", ".txt")) + raise ValueError("Unexpected file type. Please ensure files are .dbl or .txt files") def main(): @@ -201,7 +257,7 @@ def main(): action="store_true", ) parser.add_argument( - "--common-errors", help="Show common misrecognitions", action="store_true" + "--show-errors", help="Print errors separately", action="store_true" ) parser.add_argument( "--cer", @@ -219,47 +275,18 @@ def main(): normaliser = BasicTextNormalizer() if args["non_en"] else EnglishTextNormalizer() - if ( - is_supported(args["ref_path"]) is not True - or is_supported(args["hyp_path"]) is not True - ): - raise ValueError("Unsupported file type, files must be .dbl or .txt files") - - if args["ref_path"].endswith(".dbl") and args["ref_path"].endswith(".dbl"): - ref_files = read_dbl(args["ref_path"]) - hyp_files = read_dbl(args["hyp_path"]) - assert len(ref_files) == len( - hyp_files - ), "Number of ref and hyp files should be the same" - - if args["ref_path"].endswith(".txt") and args["ref_path"].endswith(".txt"): - ref_files = [args["ref_path"]] - hyp_files = [args["hyp_path"]] - + ref_files, hyp_files = check_paths(args["ref_path"], args["hyp_path"]) results = [] for ref, hyp in zip(ref_files, hyp_files): - raw_ref = load_file(ref.strip()) - raw_hyp = load_file(hyp.strip()) - norm_ref = normaliser(raw_ref) - norm_hyp = normaliser(raw_hyp) + norm_ref = normaliser(load_file(ref.strip())) + norm_hyp = normaliser(load_file(hyp.strip())) if args["cer"] is True: - stats = cer(norm_ref, norm_hyp, return_dict=True) - stats["reference length"] = len(list(norm_ref)) - stats["accuracy"] = 1 - stats["cer"] - diff, insertions, deletions, replacements = diff_strings( - list(norm_ref), list(norm_hyp), join_token="" - ) - + differ, stats = run_cer(norm_ref, norm_hyp) else: - stats = compute_measures(norm_ref, norm_hyp) - stats["reference length"] = len(norm_ref.split()) - stats["accuracy"] = 1 - stats["wer"] - diff, insertions, deletions, replacements = diff_strings( - norm_ref.split(), norm_hyp.split(), join_token=" " - ) + differ, stats = run_wer(norm_ref, norm_hyp) stats["file name"] = hyp @@ -268,51 +295,22 @@ def main(): print("NORMALISED HYPOTHESIS:", norm_hyp, sep="\n\n", end="\n\n") if args["diff"] is True: - diff = "".join(diff) if args["cer"] is True else " ".join(diff) - print_colourised_diff(diff) + differ.print_colourised_diff() + + if args["show_errors"] is True: + differ.print_errors_by_type() - if args["common_errors"] is True: - print_errors_by_prevelance(insertions, deletions, replacements) + for metric in ["file name", "wer", "cer", "reference length", "substitutions", "deletions", "insertions"]: + res = stats.get(metric) + if res is not None: + print(f"{metric}: {res}") - if args["common_errors"] is not True and args["diff"] is True: - print_errors_chronologically(insertions, deletions, replacements) - for metric, val in stats.items(): - print(f"{metric}: {val}") results.append(stats) if args["csv"] is True: - - if args["cer"] is True: - fields = [ - "file name", - "cer", - "accuracy", - "insertions", - "deletions", - "substitutions", - "reference length", - ] - - else: - fields = [ - "file name", - "wer", - "accuracy", - "insertions", - "deletions", - "substitutions", - "reference length", - ] - - with open("results.csv", "w", encoding="utf-8") as results_csv: - writer = csv.DictWriter( - results_csv, fieldnames=fields, extrasaction="ignore" - ) - writer.writeheader() - for row in results: - writer.writerow(row) + generate_csv(results, args["cer"]) if __name__ == "__main__": From 667e9c3b3b463a5bfbca0d6ada926c12fc54569b Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 10:59:35 +0000 Subject: [PATCH 07/40] Reformat with black --- speechmatics/metrics/wer.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/speechmatics/metrics/wer.py b/speechmatics/metrics/wer.py index f82d5b5..6b03b5e 100644 --- a/speechmatics/metrics/wer.py +++ b/speechmatics/metrics/wer.py @@ -300,13 +300,19 @@ def main(): if args["show_errors"] is True: differ.print_errors_by_type() - for metric in ["file name", "wer", "cer", "reference length", "substitutions", "deletions", "insertions"]: + for metric in [ + "file name", + "wer", + "cer", + "reference length", + "substitutions", + "deletions", + "insertions", + ]: res = stats.get(metric) if res is not None: print(f"{metric}: {res}") - - results.append(stats) if args["csv"] is True: From c8a863d3d578271ddda3ab082f91610ebed368e6 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 11:08:58 +0000 Subject: [PATCH 08/40] Refactor class and format with black --- speechmatics/metrics/normalizers/basic.py | 125 ++++++++++++---------- 1 file changed, 68 insertions(+), 57 deletions(-) diff --git a/speechmatics/metrics/normalizers/basic.py b/speechmatics/metrics/normalizers/basic.py index 84c3b39..0cbfa6f 100644 --- a/speechmatics/metrics/normalizers/basic.py +++ b/speechmatics/metrics/normalizers/basic.py @@ -2,79 +2,90 @@ import unicodedata import regex -# non-ASCII letters that are not separated by "NFKD" normalization -ADDITIONAL_DIACRITICS = { - "œ": "oe", - "Œ": "OE", - "ø": "o", - "Ø": "O", - "æ": "ae", - "Æ": "AE", - "ß": "ss", - "ẞ": "SS", - "đ": "d", - "Đ": "D", - "ð": "d", - "Ð": "D", - "þ": "th", - "Þ": "th", - "ł": "l", - "Ł": "L", -} +class BasicTextNormalizer: + def __init__(self, remove_diacritics: bool = False, split_letters: bool = False): + self.clean = ( + self.remove_symbols_and_diacritics + if remove_diacritics + else self.remove_symbols + ) + self.split_letters = split_letters -def remove_symbols_and_diacritics(s: str, keep=""): - """ - Replace any other markers, symbols, and punctuations with a space, - and drop any diacritics (category 'Mn' and some manual mappings) - """ - return "".join( - c - if c in keep - else ADDITIONAL_DIACRITICS[c] - if c in ADDITIONAL_DIACRITICS - else "" - if unicodedata.category(c) == "Mn" - else " " - if unicodedata.category(c)[0] in "MSP" - else c - for c in unicodedata.normalize("NFKD", s) - ) - + # non-ASCII letters that are not separated by "NFKD" normalization + self.additional_diacritics = { + "œ": "oe", + "Œ": "OE", + "ø": "o", + "Ø": "O", + "æ": "ae", + "Æ": "AE", + "ß": "ss", + "ẞ": "SS", + "đ": "d", + "Đ": "D", + "ð": "d", + "Ð": "D", + "þ": "th", + "Þ": "th", + "ł": "l", + "Ł": "L", + } -def remove_symbols(s: str): - """ - Replace any other markers, symbols, punctuations with a space, keeping diacritics - """ - return "".join( - " " if unicodedata.category(c)[0] in "MSP" else c for c in unicodedata.normalize("NFKC", s) - ) + def remove_symbols_and_diacritics(self, s: str, keep=""): + """ + Replace any other markers, symbols, and punctuations with a space, + and drop any diacritics (category 'Mn' and some manual mappings) + """ + return "".join( + c + if c in keep + else self.additional_diacritics[c] + if c in self.additional_diacritics + else "" + if unicodedata.category(c) == "Mn" + else " " + if unicodedata.category(c)[0] in "MSP" + else c + for c in unicodedata.normalize("NFKD", s) + ) + def remove_symbols(self, s: str): + """ + Replace any other markers, symbols, punctuations with a space, keeping diacritics -class BasicTextNormalizer: + Args: + s (str): raw input transcript - def __init__(self, remove_diacritics: bool = False, split_letters: bool = False): - self.clean = remove_symbols_and_diacritics if remove_diacritics else remove_symbols - self.split_letters = split_letters + Returns: + s (str): same string which has been modified inplace + """ + return "".join( + " " if unicodedata.category(c)[0] in "MSP" else c + for c in unicodedata.normalize("NFKC", s) + ) def __call__(self, s: str) -> str: """ - Take in a string. - Return a normalised string. + Return a normalised string, given an input string, using the following modifications: - Make everything lowercase. - Replace diacritics with the ASCII eqivalent. - Remove tokens between brackets. - Replace whitespace and any remaining punctuation with single space. + Makes everything lowercase. + Remove tokens between brackets. + Replace diacritics with the ASCII eqivalent. + Replace whitespace and any remaining punctuation with single space. """ + s = s.lower() - s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) # remove words between brackets - s = re.sub(r"\(([^)]+?)\)", "", s) # remove words between parenthesis + + s = re.sub(r"[<\[][^>\]]*[>\]]", "", s) + s = re.sub(r"\(([^)]+?)\)", "", s) + s = self.clean(s).lower() + # insert a single space between characters in a string if self.split_letters: s = " ".join(regex.findall(r"\X", s, regex.U)) - s = re.sub(r"\s+", " ", s) # replace any successive whitespace characters with a space + s = re.sub(r"\s+", " ", s) return s From 4e0bcd7afe251ac3214028d23c625e32cc922ebf Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 11:09:17 +0000 Subject: [PATCH 09/40] Refactor class and format with black --- speechmatics/metrics/normalizers/english.py | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/speechmatics/metrics/normalizers/english.py b/speechmatics/metrics/normalizers/english.py index f3ba8fa..9fb2678 100644 --- a/speechmatics/metrics/normalizers/english.py +++ b/speechmatics/metrics/normalizers/english.py @@ -6,7 +6,7 @@ from more_itertools import windowed -from .basic import remove_symbols_and_diacritics +from .basic import BasicTextNormalizer class EnglishNumberNormalizer: @@ -190,13 +190,14 @@ def output(result: Union[str, int]): skip = False continue + assert isinstance(current, str) next_is_numeric = next is not None and re.match(r"^\d+(\.\d+)?$", next) has_prefix = current[0] in self.prefixes current_without_prefix = current[1:] if has_prefix else current if re.match(r"^\d+(\.\d+)?$", current_without_prefix): # arabic numbers (potentially with signs and fractions) - f = to_fraction(current_without_prefix) - assert f is not None + frac = to_fraction(current_without_prefix) + assert frac is not None if value is not None: if isinstance(value, str) and value.endswith("."): # concatenate decimals / ip address components @@ -206,8 +207,8 @@ def output(result: Union[str, int]): yield output(value) prefix = current[0] if has_prefix else prefix - if f.denominator == 1: - value = f.numerator # store integers as int + if frac.denominator == 1: + value = frac.numerator # store integers as int else: value = current_without_prefix elif current not in self.words: @@ -223,9 +224,9 @@ def output(result: Union[str, int]): if value is None: value = ones elif isinstance(value, str) or prev in self.ones: - if ( - prev in self.tens and ones < 10 - ): # replace the last zero with the digit + # replace the last zero with the digit + if prev in self.tens and ones < 10: + assert isinstance(value, str) assert value[-1] == "0" value = value[:-1] + str(ones) else: @@ -290,9 +291,9 @@ def output(result: Union[str, int]): if value is None: value = multiplier elif isinstance(value, str) or value == 0: - f = to_fraction(value) - p = f * multiplier if f is not None else None - if f is not None and p.denominator == 1: + frac = to_fraction(value) + p = frac * multiplier if frac is not None else None + if frac is not None and p.denominator == 1: value = p.numerator else: yield output(value) @@ -306,9 +307,9 @@ def output(result: Union[str, int]): if value is None: yield output(str(multiplier) + suffix) elif isinstance(value, str): - f = to_fraction(value) - p = f * multiplier if f is not None else None - if f is not None and p.denominator == 1: + frac = to_fraction(value) + p = frac * multiplier if frac is not None else None + if frac is not None and p.denominator == 1: yield output(str(p.numerator) + suffix) else: yield output(value) @@ -463,14 +464,15 @@ class EnglishSpellingNormalizer: def __init__(self): mapping_path = os.path.join(os.path.dirname(__file__), "english.json") - self.mapping = json.load(open(mapping_path)) + self.mapping = json.load(open(mapping_path, "r", encoding="utf-8")) def __call__(self, s: str): return " ".join(self.mapping.get(word, word) for word in s.split()) -class EnglishTextNormalizer: +class EnglishTextNormalizer(BasicTextNormalizer): def __init__(self): + super().__init__() # hesitations to be removed self.ignore_patterns = r"\b(hmm|mm|mhm|mmm|uh|um)\b" self.replacers = { @@ -551,7 +553,7 @@ def __call__(self, s: str): s = re.sub(r"\.([^0-9]|$)", r" \1", s) # keep some symbols for numerics - s = remove_symbols_and_diacritics(s, keep=".%$¢€£") + s = self.remove_symbols_and_diacritics(s, keep=".%$¢€£") s = self.standardize_numbers(s) s = self.standardize_spellings(s) From d40152a70f471639dd8732eb12dc78e63b250cb7 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 11:10:00 +0000 Subject: [PATCH 10/40] Add more-itertools --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a0b6515..78da693 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ websockets>=10 httpx[http2]~=0.22 polling2~=0.5 -toml~=0.10.2 \ No newline at end of file +toml~=0.10.2 +more-itertools \ No newline at end of file From d56d1ffe6c28d846ff689c4a9d9d17411f494604 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Fri, 17 Mar 2023 19:41:25 +0000 Subject: [PATCH 11/40] refactor whisper normalizers for linting --- speechmatics/metrics/normalizers/basic.py | 43 ++-- speechmatics/metrics/normalizers/english.py | 242 ++++++++++---------- 2 files changed, 144 insertions(+), 141 deletions(-) diff --git a/speechmatics/metrics/normalizers/basic.py b/speechmatics/metrics/normalizers/basic.py index 0cbfa6f..697daf3 100644 --- a/speechmatics/metrics/normalizers/basic.py +++ b/speechmatics/metrics/normalizers/basic.py @@ -3,13 +3,25 @@ import regex +def remove_symbols(s: str): + """ + Replace any other markers, symbols, punctuations with a space, keeping diacritics + + Args: + s (str): raw input transcript + + Returns: + s (str): same string which has been modified inplace + """ + return "".join( + " " if unicodedata.category(c)[0] in "MSP" else c + for c in unicodedata.normalize("NFKC", s) + ) + + class BasicTextNormalizer: def __init__(self, remove_diacritics: bool = False, split_letters: bool = False): - self.clean = ( - self.remove_symbols_and_diacritics - if remove_diacritics - else self.remove_symbols - ) + self.remove_diacritics = remove_diacritics self.split_letters = split_letters # non-ASCII letters that are not separated by "NFKD" normalization @@ -50,20 +62,11 @@ def remove_symbols_and_diacritics(self, s: str, keep=""): for c in unicodedata.normalize("NFKD", s) ) - def remove_symbols(self, s: str): - """ - Replace any other markers, symbols, punctuations with a space, keeping diacritics - - Args: - s (str): raw input transcript - - Returns: - s (str): same string which has been modified inplace - """ - return "".join( - " " if unicodedata.category(c)[0] in "MSP" else c - for c in unicodedata.normalize("NFKC", s) - ) + def clean(self, s: str): + "Return a string without symbols and optionally without diacritics, given input string" + if self.remove_diacritics is True: + return self.remove_symbols_and_diacritics(s) + return remove_symbols(s) def __call__(self, s: str) -> str: """ @@ -84,7 +87,7 @@ def __call__(self, s: str) -> str: # insert a single space between characters in a string if self.split_letters: - s = " ".join(regex.findall(r"\X", s, regex.U)) + s = " ".join(regex.findall(r"\X", s, re.UNICODE)) s = re.sub(r"\s+", " ", s) diff --git a/speechmatics/metrics/normalizers/english.py b/speechmatics/metrics/normalizers/english.py index 9fb2678..b362bab 100644 --- a/speechmatics/metrics/normalizers/english.py +++ b/speechmatics/metrics/normalizers/english.py @@ -8,6 +8,30 @@ from .basic import BasicTextNormalizer +def postprocess(s: str): + def combine_cents(match: Match): + try: + currency = match.group(1) + integer = match.group(2) + cents = int(match.group(3)) + return f"{currency}{integer}.{cents:02d}" + except ValueError: + return match.string + + def extract_cents(match: Match): + try: + return f"¢{int(match.group(1))}" + except ValueError: + return match.string + + # apply currency postprocessing; "$2 and ¢7" -> "$2.07" + s = re.sub(r"([€£$])([0-9]+) (?:and )?¢([0-9]{1,2})\b", combine_cents, s) + s = re.sub(r"[€£$]0.([0-9]{1,2})\b", extract_cents, s) + + # write "one(s)" instead of "1(s)", just for the readability + s = re.sub(r"\b1(s?)\b", r"one\1", s) + + return s class EnglishNumberNormalizer: """ @@ -141,8 +165,7 @@ def __init__(self): } self.specials = {"and", "double", "triple", "point"} - self.words = set( - [ + self.words = { key for mapping in [ self.zeros, @@ -158,42 +181,53 @@ def __init__(self): self.specials, ] for key in mapping - ] - ) + } self.literal_words = {"one", "ones"} def process_words(self, words: List[str]) -> Iterator[str]: prefix: Optional[str] = None value: Optional[Union[str, int]] = None - skip = False + skip: bool = False - def to_fraction(s: str): + def to_fraction(s: str) -> Union[Fraction, None]: + "Convert input string into a Fraction object or return None" try: return Fraction(s) except ValueError: return None def output(result: Union[str, int]): + """ + Prepend any prefix to result and return as a string. + + Reset the prefix and value to None. + """ nonlocal prefix, value result = str(result) + if prefix is not None: result = prefix + result - value = None - prefix = None + + value, prefix = None, None return result if len(words) == 0: return - for prev, current, next in windowed([None] + words + [None], 3): - if skip: + for prev_word, current_word, next_word in windowed([None] + words + [None], 3): + if skip is True: skip = False continue - assert isinstance(current, str) - next_is_numeric = next is not None and re.match(r"^\d+(\.\d+)?$", next) - has_prefix = current[0] in self.prefixes - current_without_prefix = current[1:] if has_prefix else current + assert isinstance(current_word, str) + # find if next word is an integer or float string + next_is_numeric: bool = next_word is not None and bool( + re.match(r"^\d+(\.\d+)?$", next_word) + ) + has_prefix: bool = current_word[0] in self.prefixes + current_without_prefix: str = ( + current_word[1:] if has_prefix else current_word + ) if re.match(r"^\d+(\.\d+)?$", current_without_prefix): # arabic numbers (potentially with signs and fractions) frac = to_fraction(current_without_prefix) @@ -201,34 +235,33 @@ def output(result: Union[str, int]): if value is not None: if isinstance(value, str) and value.endswith("."): # concatenate decimals / ip address components - value = str(value) + str(current) + value = str(value) + str(current_word) continue - else: - yield output(value) + yield output(value) - prefix = current[0] if has_prefix else prefix + prefix = current_word[0] if has_prefix else prefix if frac.denominator == 1: - value = frac.numerator # store integers as int + value = frac.numerator # int else: - value = current_without_prefix - elif current not in self.words: + value = current_without_prefix # str + elif current_word not in self.words: # non-numeric words if value is not None: yield output(value) - yield output(current) - elif current in self.zeros: + yield output(current_word) + elif current_word in self.zeros: value = str(value or "") + "0" - elif current in self.ones: - ones = self.ones[current] + elif current_word in self.ones: + ones = self.ones[current_word] if value is None: value = ones - elif isinstance(value, str) or prev in self.ones: + elif isinstance(value, str) or prev_word in self.ones: # replace the last zero with the digit - if prev in self.tens and ones < 10: - assert isinstance(value, str) - assert value[-1] == "0" - value = value[:-1] + str(ones) + if prev_word in self.tens and ones < 10: + value = str(value) + if value and value[-1] == "0": + value = value[:-1] + str(ones) else: value = str(value) + str(ones) elif ones < 10: @@ -241,14 +274,14 @@ def output(result: Union[str, int]): value += ones else: value = str(value) + str(ones) - elif current in self.ones_suffixed: + elif current_word in self.ones_suffixed: # ordinal or cardinal; yield the number right away - ones, suffix = self.ones_suffixed[current] + ones, suffix = self.ones_suffixed[current_word] if value is None: yield output(str(ones) + suffix) - elif isinstance(value, str) or prev in self.ones: - if prev in self.tens and ones < 10: - assert value[-1] == "0" + elif isinstance(value, str) or prev_word in self.ones: + if prev_word in self.tens and ones < 10: + value = str(value) yield output(value[:-1] + str(ones) + suffix) else: yield output(str(value) + str(ones) + suffix) @@ -263,8 +296,8 @@ def output(result: Union[str, int]): else: yield output(str(value) + str(ones) + suffix) value = None - elif current in self.tens: - tens = self.tens[current] + elif current_word in self.tens: + tens = self.tens[current_word] if value is None: value = tens elif isinstance(value, str): @@ -274,9 +307,9 @@ def output(result: Union[str, int]): value += tens else: value = str(value) + str(tens) - elif current in self.tens_suffixed: + elif current_word in self.tens_suffixed: # ordinal or cardinal; yield the number right away - tens, suffix = self.tens_suffixed[current] + tens, suffix = self.tens_suffixed[current_word] if value is None: yield output(str(tens) + suffix) elif isinstance(value, str): @@ -286,15 +319,15 @@ def output(result: Union[str, int]): yield output(str(value + tens) + suffix) else: yield output(str(value) + str(tens) + suffix) - elif current in self.multipliers: - multiplier = self.multipliers[current] + elif current_word in self.multipliers: + multiplier = self.multipliers[current_word] if value is None: value = multiplier elif isinstance(value, str) or value == 0: - frac = to_fraction(value) - p = frac * multiplier if frac is not None else None - if frac is not None and p.denominator == 1: - value = p.numerator + frac = to_fraction(str(value)) + multiplied_frac = frac * multiplier if frac is not None else None + if frac is not None and multiplied_frac.denominator == 1: + value = multiplied_frac.numerator else: yield output(value) value = multiplier @@ -302,15 +335,15 @@ def output(result: Union[str, int]): before = value // 1000 * 1000 residual = value % 1000 value = before + residual * multiplier - elif current in self.multipliers_suffixed: - multiplier, suffix = self.multipliers_suffixed[current] + elif current_word in self.multipliers_suffixed: + multiplier, suffix = self.multipliers_suffixed[current_word] if value is None: yield output(str(multiplier) + suffix) elif isinstance(value, str): frac = to_fraction(value) - p = frac * multiplier if frac is not None else None - if frac is not None and p.denominator == 1: - yield output(str(p.numerator) + suffix) + multiplied_frac = frac * multiplier if frac is not None else None + if frac is not None and multiplied_frac.denominator == 1: + yield output(str(multiplied_frac.numerator) + suffix) else: yield output(value) yield output(str(multiplier) + suffix) @@ -320,66 +353,66 @@ def output(result: Union[str, int]): value = before + residual * multiplier yield output(str(value) + suffix) value = None - elif current in self.preceding_prefixers: + elif current_word in self.preceding_prefixers: # apply prefix (positive, minus, etc.) if it precedes a number if value is not None: yield output(value) - if next in self.words or next_is_numeric: - prefix = self.preceding_prefixers[current] + if next_word in self.words or next_is_numeric: + prefix = self.preceding_prefixers[current_word] else: - yield output(current) - elif current in self.following_prefixers: + yield output(current_word) + elif current_word in self.following_prefixers: # apply prefix (dollars, cents, etc.) only after a number if value is not None: - prefix = self.following_prefixers[current] + prefix = self.following_prefixers[current_word] yield output(value) else: - yield output(current) - elif current in self.suffixers: + yield output(current_word) + elif current_word in self.suffixers: # apply suffix symbols (percent -> '%') if value is not None: - suffix = self.suffixers[current] + suffix = self.suffixers[current_word] if isinstance(suffix, dict): - if next in suffix: - yield output(str(value) + suffix[next]) + if next_word in suffix: + yield output(str(value) + suffix[next_word]) skip = True else: yield output(value) - yield output(current) + yield output(current_word) else: yield output(str(value) + suffix) else: - yield output(current) - elif current in self.specials: - if next not in self.words and not next_is_numeric: + yield output(current_word) + elif current_word in self.specials: + if next_word not in self.words and not next_is_numeric: # apply special handling only if the next word can be numeric if value is not None: yield output(value) - yield output(current) - elif current == "and": + yield output(current_word) + elif current_word == "and": # ignore "and" after hundreds, thousands, etc. - if prev not in self.multipliers: + if prev_word not in self.multipliers: if value is not None: yield output(value) - yield output(current) - elif current == "double" or current == "triple": - if next in self.ones or next in self.zeros: - repeats = 2 if current == "double" else 3 - ones = self.ones.get(next, 0) + yield output(current_word) + elif current_word in ("double", "triple"): + if next_word in self.ones or next_word in self.zeros: + repeats = 2 if current_word == "double" else 3 + ones = self.ones.get(next_word, 0) value = str(value or "") + str(ones) * repeats skip = True else: if value is not None: yield output(value) - yield output(current) - elif current == "point": - if next in self.decimals or next_is_numeric: + yield output(current_word) + elif current_word == "point": + if next_word in self.decimals or next_is_numeric: value = str(value or "") + "." else: - raise ValueError(f"Unexpected token: {current}") + raise ValueError(f"Unexpected token: {current_word}") else: - raise ValueError(f"Unexpected token: {current}") + raise ValueError(f"Unexpected token: {current_word}") if value is not None: yield output(value) @@ -392,7 +425,7 @@ def preprocess(self, s: str): s (str): The string to be preprocessed Returns: - s (str): the preprocessed stringm, with entities standardised + s (str): the preprocessed string, with entities standardised """ # replace " and a half" with " point five" results = [] @@ -422,57 +455,24 @@ def preprocess(self, s: str): return s - def postprocess(self, s: str): - def combine_cents(m: Match): - try: - currency = m.group(1) - integer = m.group(2) - cents = int(m.group(3)) - return f"{currency}{integer}.{cents:02d}" - except ValueError: - return m.string - - def extract_cents(m: Match): - try: - return f"¢{int(m.group(1))}" - except ValueError: - return m.string - - # apply currency postprocessing; "$2 and ¢7" -> "$2.07" - s = re.sub(r"([€£$])([0-9]+) (?:and )?¢([0-9]{1,2})\b", combine_cents, s) - s = re.sub(r"[€£$]0.([0-9]{1,2})\b", extract_cents, s) - - # write "one(s)" instead of "1(s)", just for the readability - s = re.sub(r"\b1(s?)\b", r"one\1", s) - - return s def __call__(self, s: str): s = self.preprocess(s) s = " ".join(word for word in self.process_words(s.split()) if word is not None) - s = self.postprocess(s) + s = postprocess(s) return s -class EnglishSpellingNormalizer: - """ - Applies British-American spelling mappings as listed in [1]. - - [1] https://www.tysto.com/uk-us-spelling-list.html - """ - - def __init__(self): - mapping_path = os.path.join(os.path.dirname(__file__), "english.json") - self.mapping = json.load(open(mapping_path, "r", encoding="utf-8")) - - def __call__(self, s: str): - return " ".join(self.mapping.get(word, word) for word in s.split()) - - class EnglishTextNormalizer(BasicTextNormalizer): def __init__(self): super().__init__() + + # spelling map + mapping_path = os.path.join(os.path.dirname(__file__), "english.json") + with open(mapping_path, "r", encoding="utf-8") as spelling_file: + self.mapping = json.load(spelling_file) + # hesitations to be removed self.ignore_patterns = r"\b(hmm|mm|mhm|mmm|uh|um)\b" self.replacers = { @@ -531,7 +531,6 @@ def __init__(self): r"'m\b": " am", } self.standardize_numbers = EnglishNumberNormalizer() - self.standardize_spellings = EnglishSpellingNormalizer() def __call__(self, s: str): s = s.lower() @@ -555,8 +554,9 @@ def __call__(self, s: str): # keep some symbols for numerics s = self.remove_symbols_and_diacritics(s, keep=".%$¢€£") + # standardise numbers and spellings s = self.standardize_numbers(s) - s = self.standardize_spellings(s) + s = " ".join(self.mapping.get(word, word) for word in s.split()) # now remove prefix/suffix symbols that are not preceded/followed by numbers s = re.sub(r"[.$¢€£]([^0-9])", r" \1", s) From a29d3a11aa08a79a6acfac5c8bb3c8918e726b9e Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Thu, 18 May 2023 15:54:55 +0100 Subject: [PATCH 12/40] Move metrics dir --- {speechmatics/metrics => metrics}/README.md | 0 {speechmatics/metrics => metrics}/__init__.py | 0 {speechmatics/metrics => metrics}/normalizers/LICENSE | 0 {speechmatics/metrics => metrics}/normalizers/__init__.py | 0 {speechmatics/metrics => metrics}/normalizers/basic.py | 0 {speechmatics/metrics => metrics}/normalizers/english.json | 0 {speechmatics/metrics => metrics}/normalizers/english.py | 0 {speechmatics/metrics => metrics}/wer.py | 2 +- 8 files changed, 1 insertion(+), 1 deletion(-) rename {speechmatics/metrics => metrics}/README.md (100%) rename {speechmatics/metrics => metrics}/__init__.py (100%) rename {speechmatics/metrics => metrics}/normalizers/LICENSE (100%) rename {speechmatics/metrics => metrics}/normalizers/__init__.py (100%) rename {speechmatics/metrics => metrics}/normalizers/basic.py (100%) rename {speechmatics/metrics => metrics}/normalizers/english.json (100%) rename {speechmatics/metrics => metrics}/normalizers/english.py (100%) rename {speechmatics/metrics => metrics}/wer.py (99%) diff --git a/speechmatics/metrics/README.md b/metrics/README.md similarity index 100% rename from speechmatics/metrics/README.md rename to metrics/README.md diff --git a/speechmatics/metrics/__init__.py b/metrics/__init__.py similarity index 100% rename from speechmatics/metrics/__init__.py rename to metrics/__init__.py diff --git a/speechmatics/metrics/normalizers/LICENSE b/metrics/normalizers/LICENSE similarity index 100% rename from speechmatics/metrics/normalizers/LICENSE rename to metrics/normalizers/LICENSE diff --git a/speechmatics/metrics/normalizers/__init__.py b/metrics/normalizers/__init__.py similarity index 100% rename from speechmatics/metrics/normalizers/__init__.py rename to metrics/normalizers/__init__.py diff --git a/speechmatics/metrics/normalizers/basic.py b/metrics/normalizers/basic.py similarity index 100% rename from speechmatics/metrics/normalizers/basic.py rename to metrics/normalizers/basic.py diff --git a/speechmatics/metrics/normalizers/english.json b/metrics/normalizers/english.json similarity index 100% rename from speechmatics/metrics/normalizers/english.json rename to metrics/normalizers/english.json diff --git a/speechmatics/metrics/normalizers/english.py b/metrics/normalizers/english.py similarity index 100% rename from speechmatics/metrics/normalizers/english.py rename to metrics/normalizers/english.py diff --git a/speechmatics/metrics/wer.py b/metrics/wer.py similarity index 99% rename from speechmatics/metrics/wer.py rename to metrics/wer.py index 6b03b5e..5587219 100644 --- a/speechmatics/metrics/wer.py +++ b/metrics/wer.py @@ -8,7 +8,7 @@ from collections import Counter from argparse import ArgumentParser from jiwer import compute_measures, cer -from speechmatics.metrics.normalizers import BasicTextNormalizer, EnglishTextNormalizer +from metrics.normalizers import BasicTextNormalizer, EnglishTextNormalizer def load_file(path: str) -> str: From 0aedcae14935dd2003cf5f0e6bd63c567559a4c9 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Thu, 18 May 2023 17:24:32 +0100 Subject: [PATCH 13/40] Update README --- metrics/README.md | 4 ++-- metrics/wer.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/metrics/README.md b/metrics/README.md index 1779864..64b3e50 100644 --- a/metrics/README.md +++ b/metrics/README.md @@ -36,13 +36,13 @@ Accuracy is the complement of WER. That is, if the WER of an ASR transcript if 5 This WER tool is built using the JiWER library. Install it as follows: ```bash -pip3 install jiwer +pip3 install jiwer regex ``` To compute the WER and show a transcript highlighting the difference between the Reference and the Hypothesis, run the following: ```bash -python3 wer.py --diff +python3 -m metrics.wer --diff ``` ## Read More diff --git a/metrics/wer.py b/metrics/wer.py index 5587219..dff7d7c 100644 --- a/metrics/wer.py +++ b/metrics/wer.py @@ -271,7 +271,6 @@ def main(): parser.add_argument("ref_path", help="Path to the reference transcript", type=str) parser.add_argument("hyp_path", help="Path to the hypothesis transcript", type=str) args = vars(parser.parse_args()) - print(args) normaliser = BasicTextNormalizer() if args["non_en"] else EnglishTextNormalizer() From e216866c5d658a9028a71f76a0552507a691f562 Mon Sep 17 00:00:00 2001 From: Dan Cochrane Date: Thu, 18 May 2023 17:38:14 +0100 Subject: [PATCH 14/40] Add example transcripts for WER --- metrics/examples/hypothesis-wer.txt | 1 + metrics/examples/reference-wer.txt | 1 + 2 files changed, 2 insertions(+) create mode 100644 metrics/examples/hypothesis-wer.txt create mode 100644 metrics/examples/reference-wer.txt diff --git a/metrics/examples/hypothesis-wer.txt b/metrics/examples/hypothesis-wer.txt new file mode 100644 index 0000000..913d9b3 --- /dev/null +++ b/metrics/examples/hypothesis-wer.txt @@ -0,0 +1 @@ +Gary and Paul O'Donovan from Skibbereen. Olympic silver medallist. How does it feel? It's fantastic. We haven't haven't had too much of a chance yet to come to terms with it. We wanted to win the gold medal and come over with a silver medal. Like we're just so happy. We can't complain with that. Paul Go For me to be as fast as you can and pull like a dog. Fourth at 500. Fourth at 1000. You pulled fairly hard in that last closing stages of that race. We did. Josh We're a little bit disappointed we didn't come away with the gold medal. I think we put it up to the French as best we could and we dived close there at the end. I'd say we were going all over the lane, but we're dreading going home now because Mick Conlan said he boxed the head office if we didn't get to go. So there was only a split second in it. But lads, you've secured Ireland's first ever Olympic rowing medal. Yeah, we're delighted, you know. We set out when we qualified last year, finishing 11th at the World Championships. Our goal was to win the Olympics and we knew we'd have to beat one of the best crews in the world in France. And then they went and made their crew a little bit stronger, putting in a new guy to come away so close to winning the silver medal. You know, we we have to go home happy. And I don't know if you're aware. I don't know if you were aware. Yeah, you deserve it. I saw him come up here. I've been racing the bombing since 2013 and my first world championships in the doubles. So to be on a podium with him, I don't know if you. If you're aware the huge interest this has garnered at home that the fact the whole of Ireland not to mind the whole of Skibbereen is watching. What does it mean after all the years of work, starting off at junior level, working your way up to be here today? That's good. Yeah, we're just happy to do it for the sport, really. And like I was saying, there's a big buzz around rowing now, so just a fantastic sport. So we're just hoping that a lot more people will start to realise that and maybe take it up and give it a go. And you never know. Like there's plenty people out there with two arms and two legs like the two of ourselves. So there could be more Olympic champions to come. Please God. And a wonderful day for the people back in the club, the likes of Dominic Casey, who's who's been in, I believe, involved with Skibbereen for about 30 years. Yeah, probably more since since day one, pretty much. I can't wait to run into him. Yeah. Can't wait to put the silver medal around possible without him really going to put the silver medal around his neck. Do you know he is a young family at home and he spent more than half of this year with us. And we are in training camps and stuff like that. So it's he's given up a lot of his time, too. So the time it's a huge credit to him and everyone else in the rowing club as well. And Joe Martin Espersen as well for for allowing us to do it with our club coach as well. Yeah. Very final question for you. What do you want to say to the people back home? We're hugely honoured and so proud to be representing Ireland and everybody at home in Ireland, and we've been getting such huge support and we've gotten such a big following following some of our interviews. I mean, you know, you've just been asking us questions and we'd have been answering them. People have been enjoying it. So, you know, we're so proud to people. The people are kind of following us and supporting us and that we take great, great pride in that. Well, Gary and Paul, thanks so much for joining us. And well, well done, lads. You've done your country proud. Cheers. Cheers. Chuck it all. \ No newline at end of file diff --git a/metrics/examples/reference-wer.txt b/metrics/examples/reference-wer.txt new file mode 100644 index 0000000..d905f49 --- /dev/null +++ b/metrics/examples/reference-wer.txt @@ -0,0 +1 @@ +Gary and Paul O'Donovan from Skibbereen. Olympic silver medallists. How does it feel? It's fantastic. We haven't haven't had too much of a chance yet to come to terms with it. We wanted to win the gold medal and come over with a silver medal, like we're just so happy. We can't complain with that. Paul ​go ​from ​A to ​B as fast as you can and pull like a dog. Fourth at 500. Fourth at 1000. You pulled fairly hard in that last closing stages of that race. We did. Josh We're a little bit disappointed we didn't come away with the gold medal. I think we put it up to the French as best we could and we dived close there at the end. I'd say we were going all over the lane, but we're dreading going home now because Mick Conlan said he box the head office if we didn't get to go so. There was only a split second in it. But lads, you've secured Ireland's first ever Olympic rowing medal. Yeah, we're delighted, you know. We set out when we qualified last year, finishing 11th at the World Championships. Our goal was to win the Olympics and we knew we'd have to beat one of the best crews in the world in France. And then they went and made their crew a little bit stronger, putting in a new guy to come away so close to winning the silver medal. You know, we we have to go home happy. And I don't know if you're aware. I don't know if you were aware. Yeah, thank you very much. You deserve it. Awesome, well done Pierre. I've been racing the Pierre Houin since 2013 at my first world championships in the doubles. So to be on a podium with him. I don't know if you. If you're aware the huge interest this has garnered at home that the fact the whole of Ireland not to mind the whole of Skibbereen is watching. What does it mean after all the years of work, starting off at junior level, working your way up to be here today? It's good. Yeah, we're just happy to do it for the sport, really. And like I was saying, there's a big buzz around rowing now, so just a fantastic sport. So we're just hoping that a lot more people will start to realise that and maybe take it up and give it a go. And you never know. Like there's plenty people out there with two arms and two legs like the two of ourselves. So there could be more Olympic champions to come. Please God. And a wonderful day for the people back in the club, the likes of Dominic Casey, who's who's been in, I believe, involved with Skibbereen for about 30 years. Yeah, probably more since since day one, pretty much. I can't wait to run into him. Yeah. Can't wait to put the silver medal around. It wouldn't be possible without him really, no. Going to put the silver medal around his neck. Do you know he has a young family at home and he spent more than half of this year with us. And we are in training camps and stuff like that. So it's he's given up a lot of his time, too. So the time it's a huge credit to him and everyone else in the rowing club as well. And do you know Morten Espersen as well for for allowing us to do it with our club coach as well. Yeah. Very final question for you. What do you want to say to the people back home? We're hugely honoured and so proud to be representing Ireland and everybody at home in Ireland, and we've been getting such huge support and we've gotten such a big following following some of our interviews. I mean, you know, you've just been asking us questions and we'd have been answering them. People have been enjoying it. So, you know, we're so proud to people. The people are kind of following us and supporting us and that we take great, great pride in that. Well, Gary and Paul, thanks so much for joining us. And well, well done, lads. You've done your country proud. Cheers. Cheers. \ No newline at end of file From b8ed94aa8601c7628855697c3bc823c9e0c53453 Mon Sep 17 00:00:00 2001 From: Ellena Reid Date: Thu, 18 May 2023 18:04:02 +0100 Subject: [PATCH 15/40] add diarization metrics --- metrics/diarization/Makefile | 13 + metrics/diarization/README.md | 26 + metrics/diarization/requirements.txt | 4 + metrics/diarization/setup.py | 32 + .../sm_diarization_metrics/README.md | 21 + .../sm_diarization_metrics/__init__.py | 0 .../diarization/sm_diarization_metrics/cli.py | 655 +++++++++++++ .../sm_diarization_metrics/cookbook.py | 891 ++++++++++++++++++ .../metrics/__init__.py | 36 + .../metrics/_version.py | 450 +++++++++ .../sm_diarization_metrics/metrics/base.py | 402 ++++++++ .../metrics/binary_classification.py | 220 +++++ .../metrics/detection.py | 453 +++++++++ .../metrics/diarization.py | 741 +++++++++++++++ .../metrics/errors/__init__.py | 29 + .../metrics/errors/identification.py | 288 ++++++ .../metrics/errors/segmentation.py | 107 +++ .../metrics/identification.py | 257 +++++ .../sm_diarization_metrics/metrics/matcher.py | 173 ++++ .../metrics/plot/__init__.py | 27 + .../metrics/plot/binary_classification.py | 179 ++++ .../metrics/segmentation.py | 382 ++++++++ .../metrics/spotting.py | 302 ++++++ .../sm_diarization_metrics/metrics/utils.py | 208 ++++ .../sm_diarization_metrics/metrics/words.py | 149 +++ .../sm_diarisation_metrics.pdf | Bin 0 -> 299751 bytes .../sm_diarization_metrics/utils.py | 256 +++++ 27 files changed, 6301 insertions(+) create mode 100644 metrics/diarization/Makefile create mode 100644 metrics/diarization/README.md create mode 100644 metrics/diarization/requirements.txt create mode 100644 metrics/diarization/setup.py create mode 100644 metrics/diarization/sm_diarization_metrics/README.md create mode 100644 metrics/diarization/sm_diarization_metrics/__init__.py create mode 100644 metrics/diarization/sm_diarization_metrics/cli.py create mode 100644 metrics/diarization/sm_diarization_metrics/cookbook.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/__init__.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/_version.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/base.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/binary_classification.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/detection.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/diarization.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/errors/__init__.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/errors/identification.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/errors/segmentation.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/identification.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/matcher.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/plot/__init__.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/plot/binary_classification.py create mode 100755 metrics/diarization/sm_diarization_metrics/metrics/segmentation.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/spotting.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/utils.py create mode 100644 metrics/diarization/sm_diarization_metrics/metrics/words.py create mode 100755 metrics/diarization/sm_diarization_metrics/sm_diarisation_metrics.pdf create mode 100644 metrics/diarization/sm_diarization_metrics/utils.py diff --git a/metrics/diarization/Makefile b/metrics/diarization/Makefile new file mode 100644 index 0000000..66fe800 --- /dev/null +++ b/metrics/diarization/Makefile @@ -0,0 +1,13 @@ +clean_files := .deps .deps-dev build dist + +.PHONY: clean +clean: + $(RM) -rf $(clean_files) + +.PHONY: wheel +wheel: + (pip install wheel && python3 setup.py bdist_wheel) + +.PHONY: install +install: + pip install ./dist/* diff --git a/metrics/diarization/README.md b/metrics/diarization/README.md new file mode 100644 index 0000000..42aced5 --- /dev/null +++ b/metrics/diarization/README.md @@ -0,0 +1,26 @@ +## Getting Started + +This project is Speechmatics' fork of https://github.com/pyannote/pyannote-metrics used to calculate various speaker diarization metrics from reference/hypothesis transcript pairs. + +This package has a CLI supporting ctm, lab, or V2 JSON format transcripts and can be run using eg +`python3 -m sm_diarisation_metrics.cookbook reference.json transcript.json` + +### Run from source code + +If you would prefer to clone the repo and run the source code: +`git clone git@github.com:speechmatics/speechmatics-python.git` +`cd speechmatics-python/metrics/sm_diarization_metrics` +`pip install -r ./requirements.txt` +`python3 -m sm_diarization_metrics.cookbook reference.json transcript.json` +`` + +### Build wheel +To build and install the wheel run: + +$ make wheel +$ make install + +### Docs + +A description of each of the metrics is available in sm_diarization_metrics.pdf + diff --git a/metrics/diarization/requirements.txt b/metrics/diarization/requirements.txt new file mode 100644 index 0000000..1484eb0 --- /dev/null +++ b/metrics/diarization/requirements.txt @@ -0,0 +1,4 @@ +pyannote.core +pyannote.database +docopt +tabulate \ No newline at end of file diff --git a/metrics/diarization/setup.py b/metrics/diarization/setup.py new file mode 100644 index 0000000..a8a5037 --- /dev/null +++ b/metrics/diarization/setup.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""Package module.""" + +import os + +from pip._internal.req import parse_requirements +from setuptools import find_packages, setup + +requirements = parse_requirements("./requirements.txt", session=False) + +git_tag = os.environ.get("CI_COMMIT_TAG") +if git_tag: + assert git_tag.startswith("diarization-metrics") +version = git_tag.lstrip("diarization-metrics/") if git_tag else "0.0.1" + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup( + author="Speechmatics", + author_email="support@speechmatics.com", + description="Python module for evaluating speaker diarization.", + install_requires=[str(r.requirement) for r in requirements], + name="speechmatics_diarization_metrics", + license="Speechmatics Proprietary License", + packages=find_packages(exclude=("tests",)), + platforms=["linux"], + python_requires=">=3.5", + version=version, + long_description=read('README.md'), + long_description_content_type='text/markdown' +) diff --git a/metrics/diarization/sm_diarization_metrics/README.md b/metrics/diarization/sm_diarization_metrics/README.md new file mode 100644 index 0000000..fc8d15e --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/README.md @@ -0,0 +1,21 @@ +# Diarisation metrics we use + +In cookbook.py, there're the implementations of three metrics used to evaluate diarisation performance. They are diarisation error rate (DER), +diarisation purity (DP), and diarisation coverage (DC). + +## Diarisation error rate (DER) + +Diarisation error rate (DER) is the standard metric for evaluating and comparing speaker diarisation systems. It is defined as follows: +### DER = ( false alarm + missed detection + confusion ) / total +Where false alarm is the duration of non-speech incorrectly classified as speech, missed detection is the duration of speech incorrectly classified as non-speech, confusion is the duration of speaker confusion, and total is the total duration of speech in the reference. + +## Diarisation purity (DP) and diarisation coverage (DC) + +While the diarisation error rate provides a convenient way to compare different diarisation approaches, it is usually not enough to understand the type of errors committed by the system.Purity and coverage are two dual evaluation metrics that provide additional insight on the behavior of the system. + +A hypothesized annotation has perfect purity if all of its labels overlap only segments which are members of a single reference label. Similarly, A hypothesized annotation has perfect coverage if all segments from a given reference label are clustered in the same cluster. + +Over-segmented results (e.g. too many speaker clusters) tend to lead to high purity and low coverage, while under-segmented results (e.g. when two speakers are merged into one large cluster) lead to low purity and higher coverage. + +## More info in reference +* http://pyannote.github.io/pyannote-metrics/reference.html#evaluation-metrics diff --git a/metrics/diarization/sm_diarization_metrics/__init__.py b/metrics/diarization/sm_diarization_metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrics/diarization/sm_diarization_metrics/cli.py b/metrics/diarization/sm_diarization_metrics/cli.py new file mode 100644 index 0000000..841415b --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/cli.py @@ -0,0 +1,655 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2017-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Herve BREDIN - http://herve.niderb.fr + +""" +Evaluation + +Usage: + sm_diarisation_metrics.metrics.py detection [--subset= --collar= --skip-overlap] / + + sm_diarisation_metrics.metrics.py segmentation [--subset= --tolerance=] / + + sm_diarisation_metrics.metrics.py overlap [--subset= --collar=] / + + sm_diarisation_metrics.metrics.py diarization [--subset= --greedy --collar= --skip-overlap] / + + sm_diarisation_metrics.metrics.py identification [--subset= --collar= --skip-overlap] / + + sm_diarisation_metrics.metrics.py spotting [--subset= --latency=... --filter=...] / + + sm_diarisation_metrics.metrics.py -h | --help + sm_diarisation_metrics.metrics.py --version + +Options: + Set evaluation protocol (e.g. "Etape.SpeakerDiarization.TV") + --subset= Evaluated subset (train|developement|test) [default: test] + --collar= Collar, in seconds [default: 0.0]. + --skip-overlap Do not evaluate overlap regions. + --tolerance= Tolerance, in seconds [default: 0.5]. + --greedy Use greedy diarization error rate. + --latency= Evaluate with fixed latency. + --filter= Filter out target trials that do not match the + expression; e.g. use --filter="speech>10" to skip + target trials with less than 10s of speech from + the target. + -h --help Show this screen. + --version Show version. + +All modes but "spotting" expect hypothesis using the RTTM file format. +RTTM files contain one line per speech turn, using the following convention: + +SPEAKER {uri} 1 {start_time} {duration} {speaker_id} + + * uri: file identifier (as given by pyannote.database protocols) + * start_time: speech turn start time in seconds + * duration: speech turn duration in seconds + * speaker_id: speaker identifier + +"spotting" mode expects hypothesis using the following JSON file format. +It should contain a list of trial hypothesis, using the same trial order as +pyannote.database speaker spotting protocols (e.g. protocol.test_trial()) + +[ + {'uri': '', 'model_id': '', 'scores': [[, ], [, ], ... [, ]]}, + {'uri': '', 'model_id': '', 'scores': [[, ], [, ], ... [, ]]}, + {'uri': '', 'model_id': '', 'scores': [[, ], [, ], ... [, ]]}, + ... + {'uri': '', 'model_id': '', 'scores': [[, ], [, ], ... [, ]]}, +] + + * uri: file identifier (as given by pyannote.database protocols) + * model_id: target identifier (as given by pyannote.database protocols) + * [ti, vi]: [time, value] pair indicating that the system has output the + score vi at time ti (e.g. [10.2, 0.2] means that the system + gave a score of 0.2 at time 10.2s). + +Calling "spotting" mode will create a bunch of files. +* contains DET curve using the following raw file format: + +* contains latency curves using this format: + + +""" + + +import functools +import json +import sys +import warnings + +import numpy as np +import pandas as pd +from docopt import docopt +from pyannote.core import Annotation, Timeline +from pyannote.database import get_protocol +from pyannote.database.util import get_annotated, load_rttm +from tabulate import tabulate + +from .metrics.detection import DetectionAccuracy, DetectionErrorRate, DetectionPrecision, DetectionRecall +from .metrics.diarization import ( + DiarizationCoverage, + DiarizationErrorRate, + DiarizationPurity, + GreedyDiarizationErrorRate, +) +from .metrics.identification import IdentificationErrorRate, IdentificationPrecision, IdentificationRecall +from .metrics.segmentation import SegmentationCoverage, SegmentationPrecision, SegmentationPurity, SegmentationRecall +from .metrics.spotting import LowLatencySpeakerSpotting + +showwarning_orig = warnings.showwarning + + +def showwarning(message, category, *args, **kwargs): + + print(category.__name__ + ":", str(message)) + + +warnings.showwarning = showwarning + + +def to_overlap(current_file: dict) -> Annotation: + """Get overlapped speech reference annotation + + Parameters + ---------- + current_file : `dict` + File yielded by pyannote.database protocols. + + Returns + ------- + overlap : `pyannote.core.Annotation` + Overlapped speech reference. + """ + + reference = current_file["annotation"] + overlap = Timeline(uri=reference.uri) + for (s1, t1), (s2, t2) in reference.co_iter(reference): + l1 = reference[s1, t1] + l2 = reference[s2, t2] + if l1 == l2: + continue + overlap.add(s1 & s2) + return overlap.support().to_annotation() + + +def get_hypothesis(hypotheses, current_file): + """Get hypothesis for given file + + Parameters + ---------- + hypotheses : `dict` + Speaker diarization hypothesis provided by `load_rttm`. + current_file : `dict` + File description as given by pyannote.database protocols. + + Returns + ------- + hypothesis : `pyannote.core.Annotation` + Hypothesis corresponding to `current_file`. + """ + + uri = current_file["uri"] + + if uri in hypotheses: + return hypotheses[uri] + + # if the exact 'uri' is not available in hypothesis, + # look for matching substring + tmp_uri = [u for u in hypotheses if u in uri] + + # no matching speech turns. return empty annotation + if len(tmp_uri) == 0: + msg = f'Could not find hypothesis for file "{uri}"; assuming empty file.' + warnings.warn(msg) + return Annotation(uri=uri, modality="speaker") + + # exactly one matching file. return it + if len(tmp_uri) == 1: + hypothesis = hypotheses[tmp_uri[0]] + hypothesis.uri = uri + return hypothesis + + # more that one matching file. error. + msg = f'Found too many hypotheses matching file "{uri}".' + raise ValueError(msg.format(uri=uri, uris=tmp_uri)) + + +def process_one(item, hypotheses=None, metrics=None): + reference = item["annotation"] + hypothesis = get_hypothesis(hypotheses, item) + uem = get_annotated(item) + return {key: metric(reference, hypothesis, uem=uem) for key, metric in metrics.items()} + + +def get_reports(protocol, subset, hypotheses, metrics): + + process = functools.partial(process_one, hypotheses=hypotheses, metrics=metrics) + + # get items and their number + progress = protocol.progress + protocol.progress = False + items = list(getattr(protocol, subset)()) + protocol.progress = progress + + for item in items: + process(item) + + # HB. 2018-02-05: parallel processing was removed because it is not clear + # how to handle the case where the same 'uri' is processed several times + # in a possibly different order for each sub-metric... + # # heuristic to estimate the optimal number of processes + # chunksize = 20 + # processes = max(1, min(mp.cpu_count(), n_items // chunksize)) + # pool = mp.Pool(processes) + # _ = pool.map(process, items, chunksize=chunksize) + + return {key: metric.report(display=False) for key, metric in metrics.items()} + + +def reindex(report): + """Reindex report so that 'TOTAL' is the last row""" + index = list(report.index) + i = index.index("TOTAL") + return report.reindex(index[:i] + index[i + 1 :] + ["TOTAL"]) + + +def detection(protocol, subset, hypotheses, collar=0.0, skip_overlap=False): + + options = {"collar": collar, "skip_overlap": skip_overlap, "parallel": False} + + metrics = { + "error": DetectionErrorRate(**options), + "accuracy": DetectionAccuracy(**options), + "precision": DetectionPrecision(**options), + "recall": DetectionRecall(**options), + } + + report = metrics["error"].report(display=False) + accuracy = metrics["accuracy"].report(display=False) + precision = metrics["precision"].report(display=False) + recall = metrics["recall"].report(display=False) + + report["accuracy", "%"] = accuracy[metrics["accuracy"].name, "%"] + report["precision", "%"] = precision[metrics["precision"].name, "%"] + report["recall", "%"] = recall[metrics["recall"].name, "%"] + + report = reindex(report) + + columns = list(report.columns) + report = report[[columns[0]] + columns[-3:] + columns[1:-3]] + + summary = "Detection (collar = {0:g} ms{1})".format(1000 * collar, ", no overlap" if skip_overlap else "") + + headers = ( + [summary] + + [report.columns[i][0] for i in range(4)] + + ["%" if c[1] == "%" else c[0] for c in report.columns[4:]] + ) + + print( + tabulate( + report, + headers=headers, + tablefmt="simple", + floatfmt=".2f", + numalign="decimal", + stralign="left", + missingval="", + showindex="default", + disable_numparse=False, + ) + ) + + +def segmentation(protocol, subset, hypotheses, tolerance=0.5): + + options = {"tolerance": tolerance, "parallel": False} + + metrics = { + "coverage": SegmentationCoverage(**options), + "purity": SegmentationPurity(**options), + "precision": SegmentationPrecision(**options), + "recall": SegmentationRecall(**options), + } + + coverage = metrics["coverage"].report(display=False) + purity = metrics["purity"].report(display=False) + precision = metrics["precision"].report(display=False) + recall = metrics["recall"].report(display=False) + + coverage = coverage[metrics["coverage"].name] + purity = purity[metrics["purity"].name] + precision = precision[metrics["precision"].name] + recall = recall[metrics["recall"].name] + + report = pd.concat([coverage, purity, precision, recall], axis=1) + report = reindex(report) + + headers = [ + "Segmentation (tolerance = {0:g} ms)".format(1000 * tolerance), + "coverage", + "purity", + "precision", + "recall", + ] + print( + tabulate( + report, + headers=headers, + tablefmt="simple", + floatfmt=".2f", + numalign="decimal", + stralign="left", + missingval="", + showindex="default", + disable_numparse=False, + ) + ) + + +def diarization(protocol, subset, hypotheses, greedy=False, collar=0.0, skip_overlap=False): + + options = {"collar": collar, "skip_overlap": skip_overlap, "parallel": False} + + metrics = {"purity": DiarizationPurity(**options), "coverage": DiarizationCoverage(**options)} + + if greedy: + metrics["error"] = GreedyDiarizationErrorRate(**options) + else: + metrics["error"] = DiarizationErrorRate(**options) + + report = metrics["error"].report(display=False) + purity = metrics["purity"].report(display=False) + coverage = metrics["coverage"].report(display=False) + + report["purity", "%"] = purity[metrics["purity"].name, "%"] + report["coverage", "%"] = coverage[metrics["coverage"].name, "%"] + + columns = list(report.columns) + report = report[[columns[0]] + columns[-2:] + columns[1:-2]] + + report = reindex(report) + + summary = "Diarization ({0:s}collar = {1:g} ms{2})".format( + "greedy, " if greedy else "", 1000 * collar, ", no overlap" if skip_overlap else "" + ) + + headers = ( + [summary] + + [report.columns[i][0] for i in range(3)] + + ["%" if c[1] == "%" else c[0] for c in report.columns[3:]] + ) + + print( + tabulate( + report, + headers=headers, + tablefmt="simple", + floatfmt=".2f", + numalign="decimal", + stralign="left", + missingval="", + showindex="default", + disable_numparse=False, + ) + ) + + +def identification(protocol, subset, hypotheses, collar=0.0, skip_overlap=False): + + options = {"collar": collar, "skip_overlap": skip_overlap, "parallel": False} + + metrics = { + "error": IdentificationErrorRate(**options), + "precision": IdentificationPrecision(**options), + "recall": IdentificationRecall(**options), + } + + report = metrics["error"].report(display=False) + precision = metrics["precision"].report(display=False) + recall = metrics["recall"].report(display=False) + + report["precision", "%"] = precision[metrics["precision"].name, "%"] + report["recall", "%"] = recall[metrics["recall"].name, "%"] + + columns = list(report.columns) + report = report[[columns[0]] + columns[-2:] + columns[1:-2]] + + report = reindex(report) + + summary = "Identification (collar = {0:g} ms{1})".format(1000 * collar, ", no overlap" if skip_overlap else "") + + headers = ( + [summary] + + [report.columns[i][0] for i in range(3)] + + ["%" if c[1] == "%" else c[0] for c in report.columns[3:]] + ) + + print( + tabulate( + report, + headers=headers, + tablefmt="simple", + floatfmt=".2f", + numalign="decimal", + stralign="left", + missingval="", + showindex="default", + disable_numparse=False, + ) + ) + + +def spotting(protocol, subset, latencies, hypotheses, output_prefix, filter_func=None): + + if not latencies: + Scores = [] + + protocol.diarization = False + + trials = getattr(protocol, "{subset}_trial".format(subset=subset))() + for i, (current_trial, hypothesis) in enumerate(zip(trials, hypotheses)): + + # check trial/hypothesis target consistency + try: + assert current_trial["model_id"] == hypothesis["model_id"] + except AssertionError: + msg = "target mismatch in trial #{i} " "(found: {found}, should be: {should_be})" + raise ValueError(msg.format(i=i, found=hypothesis["model_id"], should_be=current_trial["model_id"])) + + # check trial/hypothesis file consistency + try: + assert current_trial["uri"] == hypothesis["uri"] + except AssertionError: + msg = "file mismatch in trial #{i} " "(found: {found}, should be: {should_be})" + raise ValueError(msg.format(i=i, found=hypothesis["uri"], should_be=current_trial["uri"])) + + # check at least one score is provided + try: + assert len(hypothesis["scores"]) > 0 + except AssertionError: + msg = "empty list of scores in trial #{i}." + raise ValueError(msg.format(i=i)) + + timestamps, scores = zip(*hypothesis["scores"]) + + if not latencies: + Scores.append(scores) + + # check trial/hypothesis timerange consistency + try_with = current_trial["try_with"] + try: + assert min(timestamps) >= try_with.start + except AssertionError: + msg = "incorrect timestamp in trial #{i} " "(found: {found:g}, should be: >= {should_be:g})" + raise ValueError(msg.format(i=i, found=min(timestamps), should_be=try_with.start)) + + if not latencies: + # estimate best set of thresholds + scores = np.concatenate(Scores) + epsilons = np.array([n * 10 ** (-e) for e in range(4, 1, -1) for n in range(1, 10)]) + percentile = np.concatenate([epsilons, np.arange(0.1, 100.0, 0.1), 100 - epsilons[::-1]]) + thresholds = np.percentile(scores, percentile) + + if not latencies: + metric = LowLatencySpeakerSpotting(thresholds=thresholds) + + else: + metric = LowLatencySpeakerSpotting(latencies=latencies) + + trials = getattr(protocol, "{subset}_trial".format(subset=subset))() + for i, (current_trial, hypothesis) in enumerate(zip(trials, hypotheses)): + + if filter_func is not None: + speech = current_trial["reference"].duration() + target_trial = speech > 0 + if target_trial and filter_func(speech): + continue + + reference = current_trial["reference"] + metric(reference, hypothesis["scores"]) + + if not latencies: + + thresholds, fpr, fnr, eer, _ = metric.det_curve(return_latency=False) + + # save DET curve to hypothesis.det.txt + det_path = "{output_prefix}.det.txt".format(output_prefix=output_prefix) + det_tmpl = "{t:.9f} {p:.9f} {n:.9f}\n" + with open(det_path, mode="w") as fp: + fp.write("# threshold false_positive_rate false_negative_rate\n") + for t, p, n in zip(thresholds, fpr, fnr): + line = det_tmpl.format(t=t, p=p, n=n) + fp.write(line) + + print("> {det_path}".format(det_path=det_path)) + + thresholds, fpr, fnr, _, _, speaker_lcy, absolute_lcy = metric.det_curve(return_latency=True) + + # save DET curve to hypothesis.det.txt + lcy_path = "{output_prefix}.lcy.txt".format(output_prefix=output_prefix) + lcy_tmpl = "{t:.9f} {p:.9f} {n:.9f} {s:.6f} {a:.6f}\n" + with open(lcy_path, mode="w") as fp: + fp.write("# threshold false_positive_rate false_negative_rate speaker_latency absolute_latency\n") + for t, p, n, s, a in zip(thresholds, fpr, fnr, speaker_lcy, absolute_lcy): + if p == 1: + continue + if np.isnan(s): + continue + line = lcy_tmpl.format(t=t, p=p, n=n, s=s, a=a) + fp.write(line) + + print("> {lcy_path}".format(lcy_path=lcy_path)) + + print() + print("EER% = {eer:.2f}".format(eer=100 * eer)) + + else: + + results = metric.det_curve() + logs = [] + for key in sorted(results): + + result = results[key] + log = {"latency": key} + for latency in latencies: + thresholds, fpr, fnr, eer, _ = result[latency] + # print('EER @ {latency}s = {eer:.2f}%'.format(latency=latency, + # eer=100 * eer)) + log[latency] = eer + # save DET curve to hypothesis.det.{lcy}s.txt + det_path = "{output_prefix}.det.{key}.{latency:g}s.txt".format( + output_prefix=output_prefix, key=key, latency=latency + ) + det_tmpl = "{t:.9f} {p:.9f} {n:.9f}\n" + with open(det_path, mode="w") as fp: + fp.write("# threshold false_positive_rate false_negative_rate\n") + for t, p, n in zip(thresholds, fpr, fnr): + line = det_tmpl.format(t=t, p=p, n=n) + fp.write(line) + logs.append(log) + det_path = "{output_prefix}.det.{key}.XXs.txt".format(output_prefix=output_prefix, key=key) + print("> {det_path}".format(det_path=det_path)) + + print() + df = 100 * pd.DataFrame.from_dict(logs).set_index("latency")[latencies] + print( + tabulate( + df, + tablefmt="simple", + headers=["latency"] + ["EER% @ {l:g}s".format(l=lat) for lat in latencies], + floatfmt=".2f", + numalign="decimal", + stralign="left", + missingval="", + showindex="default", + disable_numparse=False, + ) + ) + + +if __name__ == "__main__": + + arguments = docopt(__doc__, version="Evaluation") + + collar = float(arguments["--collar"]) + skip_overlap = arguments["--skip-overlap"] + tolerance = float(arguments["--tolerance"]) + + # protocol + protocol_name = arguments[""] + + preprocessors = dict() + if arguments["overlap"]: + if skip_overlap: + msg = "Option --skip-overlap is not supported " "when evaluating overlapped speech detection." + sys.exit(msg) + preprocessors = {"annotation": to_overlap} + + protocol = get_protocol(protocol_name, preprocessors=preprocessors) + + # subset (train, development, or test) + subset = arguments["--subset"] + + if arguments["spotting"]: + + hypothesis_json = arguments[""] + with open(hypothesis_json, mode="r") as fp: + hypotheses = json.load(fp) + + output_prefix = hypothesis_json[:-5] + + latencies = [float(lat) for lat in arguments["--latency"]] + + filters = arguments["--filter"] + if filters: + from sympy import lambdify, symbols, sympify + + def filter_speech(speech): + return any(not func(speech) for func in filter_funcs) + + speech = symbols("speech") + filter_funcs = [] + filter_funcs = [lambdify([speech], sympify(expression)) for expression in filters] + else: + + def filter_speech(speech): + return None + + spotting(protocol, subset, latencies, hypotheses, output_prefix, filter_func=filter_speech) + + sys.exit(0) + + hypothesis_rttm = arguments[""] + + try: + hypotheses = load_rttm(hypothesis_rttm) + + except FileNotFoundError: + msg = f"Could not find file {hypothesis_rttm}." + sys.exit(msg) + + except Exception: + msg = f"Failed to load {hypothesis_rttm}, please check its format " f"(only RTTM files are supported)." + sys.exit(msg) + + if arguments["detection"]: + detection(protocol, subset, hypotheses, collar=collar, skip_overlap=skip_overlap) + + if arguments["overlap"]: + detection(protocol, subset, hypotheses, collar=collar, skip_overlap=skip_overlap) + + if arguments["segmentation"]: + segmentation(protocol, subset, hypotheses, tolerance=tolerance) + + if arguments["diarization"]: + greedy = arguments["--greedy"] + diarization(protocol, subset, hypotheses, greedy=greedy, collar=collar, skip_overlap=skip_overlap) + + if arguments["identification"]: + identification(protocol, subset, hypotheses, collar=collar, skip_overlap=skip_overlap) diff --git a/metrics/diarization/sm_diarization_metrics/cookbook.py b/metrics/diarization/sm_diarization_metrics/cookbook.py new file mode 100644 index 0000000..1fa1202 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/cookbook.py @@ -0,0 +1,891 @@ +# The MIT License (MIT) + +# Copyright (c) 2021 Speechmatics + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""This module is original code from Speechmatics and contains useful +recipes for common tasks we need to perform when analysing diarisation. +This module features a CLI which is simpler and more approachable than the +large CLI offered by pyannote.metrics. +""" +import argparse +import json +import logging +import os +from enum import Enum +from typing import Optional + +import pyannote.core + +from . import utils +from .metrics import diarization as MetricsDiarization +from .metrics import segmentation as MetricsSegmentation +from .metrics import words as MetricsWords + +logger = logging.getLogger(__name__) + +# When converting to annotations, we combine adjacent segments if the speaker label is the same and assuming +# the gap is no larger than specified. This value also matches that used within the diarization system at the time +# of writing, but here we wish to apply it to the reference, as also to the word level hypothesis as given via +# the v2 json output. +SEG_MERGE_GAP = 5.0 +MERGE_GAP_NONE = 0.0 +MERGE_GAP_ANY = -1.0 + +# Segment merging type, when creating annotations from file +# Depending on the file type, we may enable / disable merging. + + +class MergeType(Enum): + NONE = 1 + ALL = 2 + JSON_ONLY = 3 + + +# Tolerance (in seconds) when matching recognized speaker change point with reference +DEFAULT_SEGMENT_TOLERANCE = 1.0 + +# Unknown speaker label +UNKNOWN_SPEAKER = "UU" + + +def print_word_der_details(words): + """Print out the word level error information""" + print("--------------------------------") + print("Word level diarization information:") + for word in words: + start_time = word[0] + end_time = word[1] + correct = word[3] + if correct: + result = "OK" + else: + result = "ERROR" + print("[WDER] {:.3f} {:.3f} {}".format(start_time, end_time, result)) + print("") + + +def f1_score(precision, recall): + """Compute the balance f-measure score (F1) from precision and recall""" + if precision + recall > 0.0: + fscore = 2.0 * (precision * recall) / (precision + recall) + else: + fscore = 0.0 + return fscore + + +def get_speaker_count_metrics(reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation) -> set: + """Get the speaker count discrepancy metrics.""" + ref_speakers = len(set(reference.labels()) - set(["UU"])) + hyp_speakers = len(set(hypothesis.labels()) - set(["UU"])) + return (ref_speakers, hyp_speakers) + + +def get_word_level_metrics(reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation) -> set: + """Get the error rate based on word level labelling.""" + metric = MetricsWords.WordDiarizationErrorRate() + metric.set_unknown_label(UNKNOWN_SPEAKER) + detailed_results = metric(reference, hypothesis, detailed=True) + nwords = detailed_results[MetricsWords.WDER_TOTAL] + words = detailed_results[MetricsWords.WDER_WORD_RESULTS] + if nwords > 0: + incorrect = detailed_results[MetricsWords.WDER_INCORRECT] + error_rate = float(incorrect) / nwords + else: + error_rate = 0.0 + + return (error_rate, nwords, words) + + +def get_der_component_details_from_annotations( + reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation +) -> set: + """Given a reference and hypothesis for the diarisation of some audio as + two `pyannote.core.Annotation` objects, returns the diarisation error rate, + together with its component details of insertion, deletion and confusion. + """ + metric = MetricsDiarization.DiarizationErrorRate() + detailed_results = metric(reference, hypothesis, detailed=True) + der = detailed_results["diarization error rate"] + total = detailed_results["total"] + insertion = detailed_results["false alarm"] / total + deletion = detailed_results["missed detection"] / total + confusion = detailed_results["confusion"] / total + + return (der, insertion, deletion, confusion) + + +def get_jaccard_error_rate_from_annotations( + reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation +) -> set: + """Given a reference and hypothesis for the diarisation of some audio as + two `pyannote.core.Annotation` objects, returns the Jaccard error rate. + """ + metric = MetricsDiarization.JaccardErrorRate() + return metric(reference, hypothesis) + + +def get_coverage_from_annotations(reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation) -> float: + """Given a reference and hypothesis for the diarisation of some audio as + two `pyannote.core.Annotation` objects, returns the diarisation coverage. + """ + metric = MetricsDiarization.DiarizationCoverage() + return metric(reference, hypothesis) + + +def get_purity_from_annotations(reference: pyannote.core.Annotation, hypothesis: pyannote.core.Annotation) -> float: + """Given a reference and hypothesis for the diarisation of some audio as + two `pyannote.core.Annotation` objects, returns the diarisation purity. + """ + metric = MetricsDiarization.DiarizationPurity() + return metric(reference, hypothesis) + + +def get_segmentation_metrics_from_annotations( + reference: pyannote.core.Annotation, + hypothesis: pyannote.core.Annotation, + tolerance: float = DEFAULT_SEGMENT_TOLERANCE, +) -> set: + """Given a reference and hypothesis for the diarisation of some audio as + two `pyannote.core.Annotation` objects, returns the speaker change metrics + (recall, precision and coverage)""" + purity = MetricsSegmentation.SegmentationPurity(tolerance=tolerance)(reference, hypothesis) + coverage = MetricsSegmentation.SegmentationCoverage(tolerance=tolerance)(reference, hypothesis) + precision = MetricsSegmentation.SegmentationPrecision(tolerance=tolerance)(reference, hypothesis) + recall = MetricsSegmentation.SegmentationRecall(tolerance=tolerance)(reference, hypothesis) + return (purity, coverage, precision, recall) + + +def remove_overlaps(annotation: pyannote.core.Annotation): + """Remove any overlaps in between the segments""" + updated_annotation = pyannote.core.Annotation() + prev_entry = None + for entry in annotation.itertracks(yield_label=True): + if prev_entry is None: + prev_entry = entry + else: + if prev_entry[0].end > entry[0].start: + # We have overlap, so split at halfway point + split_time = (prev_entry[0].end + entry[0].start) / 2.0 + updated_annotation[pyannote.core.Segment(prev_entry[0].start, split_time)] = prev_entry[2] + prev_entry = (pyannote.core.Segment(split_time, entry[0].end), entry[1], entry[2]) + else: + updated_annotation[prev_entry[0]] = prev_entry[2] + prev_entry = entry + + if prev_entry is not None: + updated_annotation[prev_entry[0]] = prev_entry[2] + + return updated_annotation + + +def merge_adjacent_segments(annotation: pyannote.core.Annotation, max_gap: float): + """Combined adjacent segments if same speaker and if gap less than max_gap""" + if max_gap == MERGE_GAP_NONE: + # No merging, just return the annotation as passed in + merged_annotation = annotation + else: + merged_annotation = pyannote.core.Annotation() + prev_entry = None + for entry in annotation.itertracks(yield_label=True): + if prev_entry is None: + prev_entry = entry + else: + if prev_entry[2] != entry[2]: + # A speaker label change, move on + merged_annotation[prev_entry[0]] = prev_entry[2] + prev_entry = entry + else: + # Speaker label is the same + gap = entry[0].start - prev_entry[0].end + if max_gap == MERGE_GAP_ANY or gap <= max_gap: + # Merge. Update the previous entry with new end time. + update_segment = pyannote.core.Segment(prev_entry[0].start, entry[0].end) + prev_entry = (update_segment, prev_entry[1], prev_entry[2]) + else: + # Do not merge. Add previous, and move on + merged_annotation[prev_entry[0]] = prev_entry[2] + prev_entry = entry + + if prev_entry is not None: + merged_annotation[prev_entry[0]] = prev_entry[2] + + return merged_annotation + + +def remove_uu(annotation: pyannote.core.Annotation): + """Remove any UU from annotation, only required for annotations produced via json""" + return annotation.subset([UNKNOWN_SPEAKER], invert=True) + + +def post_process_annotation(annotation, max_gap_merge: float, rm_unknown: bool, rm_overlaps: bool): + """Merge segments in annotation, remove unknown speaker segments, etc.""" + processed_annotation = annotation + if rm_overlaps: + processed_annotation = remove_overlaps(processed_annotation) + processed_annotation = merge_adjacent_segments(processed_annotation, max_gap_merge) + if rm_unknown: + processed_annotation = remove_uu(processed_annotation) + + return processed_annotation + + +def json_to_annotation( + json_path: str, max_gap_merge: float = SEG_MERGE_GAP, rm_unknown: bool = True, rm_overlaps: bool = True +) -> pyannote.core.Annotation: + """Takes a json file specifying word level diarization results, and converts it into a `pyannote.core.Annotation` + describing the diarisation. Note that the input format can be either Speechmatics V2 transcription, or a + standardized reference json format (where each entry in an unnamed list contains "speaker_name", "start", and + "duration") + """ + # Attempt to load in "speechmatics" transcription json format, and then back off to a + # standardised reference format if that fails. + entries = utils.load_v2_json_file(json_path) + if entries is None: + entries = utils.load_reference_json_file(json_path) + if entries is None: + # File does not apparently adhear to either supported format + raise ValueError("Unsupported diarisation json format: %s", json_path) + else: + # Speaker UU is currently only supported with V2 json input + rm_unknown = False + + annotation = pyannote.core.Annotation() + for (start_time, end_time, speaker_label) in entries: + annotation[pyannote.core.Segment(start_time, end_time)] = speaker_label + final_annotation = post_process_annotation(annotation, max_gap_merge, rm_unknown, rm_overlaps) + + return final_annotation + + +def lab_file_to_annotation( + lab_file_path: str, max_gap_merge: float = SEG_MERGE_GAP, rm_unknown: bool = True, rm_overlaps: bool = True +) -> pyannote.core.Annotation: + """Takes a label file (.lab) and converts it into a + a `pyannote.core.Annotation` describing the diarisation. + """ + entries = utils.load_lab_file(lab_file_path) + annotation = pyannote.core.Annotation() + for (start, end, speaker_label) in entries: + annotation[pyannote.core.Segment(start, end)] = speaker_label + final_annotation = post_process_annotation(annotation, max_gap_merge, rm_unknown, rm_overlaps) + return final_annotation + + +def ctm_file_to_annotation( + ctm_file_path: str, max_gap_merge: float = SEG_MERGE_GAP, rm_unknown: bool = True, rm_overlaps: bool = True +) -> pyannote.core.Annotation: + """Takes a .ctm file and converts it into a `pyannote.core.Annotation` + describing the diarisation. + """ + entries = utils.load_ctm_file(ctm_file_path) + annotation = pyannote.core.Annotation() + for (start, end, speaker_label) in entries: + annotation[pyannote.core.Segment(start, end)] = speaker_label + final_annotation = post_process_annotation(annotation, max_gap_merge, rm_unknown, rm_overlaps) + return final_annotation + + +def file_to_annotation( + file_path: str, + max_gap_merge: float = SEG_MERGE_GAP, + rm_unknown: bool = True, + rm_overlaps: bool = True, +): + """Takes a file describing diarisation which can be one of several formats (.ctm, .lab, .json) + and converts it into a `pyannote.core.Annotation` object. + """ + file_extension = file_path.split(".")[-1] + function_ptr = None + if file_extension == "ctm": + function_ptr = ctm_file_to_annotation + elif file_extension == "lab": + function_ptr = lab_file_to_annotation + elif file_extension == "json": + function_ptr = json_to_annotation + else: + raise ValueError("Unsupported diarisation file type: %s (supported extensions: ctm, json, lab)", file_extension) + + return function_ptr(file_path, max_gap_merge=max_gap_merge, rm_unknown=rm_unknown, rm_overlaps=rm_overlaps) + + +def write_annotation_to_label_file(annotation, filename): + """Write out annotation""" + with open(filename, "w") as outfp: + for entry in annotation.itertracks(yield_label=True): + outfp.write("{} {} {}\n".format(entry[0].start, entry[0].end, entry[2])) + + +def get_der_component_details_for_files(reference_file: str, hypothesis_file: str) -> set: + """Returns the diarisation error rate and its component details for a pair of files.""" + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file) + der, insertion, deletion, confusion = get_der_component_details_from_annotations( + reference_annotation, hypothesis_annotation + ) + return (der, insertion, deletion, confusion) + + +def get_unknown_speaker_count_for_files(hypothesis_file: str) -> int: + """Get the total number of unknown speaker in the hypothesis v2 json file. + Speaker labels of punctuations are not considered. + If the input file is not in v2 json format, "0" will be returned.""" + hypothesis_file_extension = hypothesis_file.split(".")[-1] + if hypothesis_file_extension == "lab": + # unknown_speaker only supported for v2 json + return 0 + entries = utils.load_v2_json_file(hypothesis_file, get_content_type=True) + if entries is None: + return 0 + unknown_speaker_count = 0 + for (_, _, speaker_label, content_type) in entries: + if content_type == "word" and speaker_label == UNKNOWN_SPEAKER: + unknown_speaker_count += 1 + return unknown_speaker_count + + +def get_coverage_for_files(reference_file: str, hypothesis_file: str) -> float: + """Returns the diarisation coverage for a pair of files.""" + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file) + diarisation_coverage = get_coverage_from_annotations(reference_annotation, hypothesis_annotation) + return diarisation_coverage + + +def get_purity_for_files(reference_file: str, hypothesis_file: str) -> float: + """Returns the diarisation purity for a pair of files.""" + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file) + diarisation_purity = get_purity_from_annotations(reference_annotation, hypothesis_annotation) + return diarisation_purity + + +def get_jaccard_error_rate_for_files(reference_file: str, hypothesis_file: str) -> set: + """Returns the Jaccard error rate for a pair of files.""" + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file) + return get_jaccard_error_rate_from_annotations(reference_annotation, hypothesis_annotation) + + +def get_segmentation_metrics_for_files( + reference_file: str, hypothesis_file: str, tolerance: float = DEFAULT_SEGMENT_TOLERANCE +) -> set: + """Returns the speaker change point metrics for a pair of files.""" + + # Note, we remove all gaps between segments of the same speaker, however large (thus we + # set the max gap to MERGE_GAP_ANY). We are only looking to analyse changes in speaker here. + reference_annotation = file_to_annotation(reference_file, rm_unknown=False, max_gap_merge=MERGE_GAP_ANY) + hypothesis_annotation = file_to_annotation(hypothesis_file, max_gap_merge=MERGE_GAP_ANY) + purity, coverage, precision, recall = get_segmentation_metrics_from_annotations( + reference_annotation, hypothesis_annotation, tolerance=tolerance + ) + return (purity, coverage, precision, recall) + + +def get_speaker_count_metrics_for_files(reference_file: str, hypothesis_file: str) -> tuple: + """Returns the speaker count metrics for a pair of files""" + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file) + ref_speakers, hyp_speakers = get_speaker_count_metrics(reference_annotation, hypothesis_annotation) + return (ref_speakers, hyp_speakers) + + +def get_word_level_metrics_for_files(reference_file: str, hypothesis_file: str) -> tuple: + """Returns the word level speaker labelling accuracy for a pair of files.""" + # Note, we leave UU in the hypothesis as we wish to consider words with UU, which will be considered + # as having the wrong label. Also, we do not perform any merging on the hypothesis, as we assume + # these are at word level, and want to keep that so as it's over words we compute the error. + reference_annotation = file_to_annotation(reference_file, rm_unknown=False) + hypothesis_annotation = file_to_annotation(hypothesis_file, max_gap_merge=MERGE_GAP_NONE, rm_unknown=False) + error_rate, nwords, words = get_word_level_metrics(reference_annotation, hypothesis_annotation) + unknown_speaker = get_unknown_speaker_count_for_files(hypothesis_file) + speaker_uu_percentage = unknown_speaker / nwords + return (error_rate, nwords, words, speaker_uu_percentage) + + +def get_diarisation_file_duration_seconds(file_path: str) -> float: + """Returns the duration of the file based on the end of the last labelled segment""" + annotation = file_to_annotation(file_path) + max_time = -1 + for entry in annotation.itertracks(yield_label=True): + max_time = max(max_time, entry[0].end) + return max_time + + +def get_diarisation_labelled_duration_seconds(file_path: str) -> float: + """Returns the duration of labelled data (not including UU) in the file.""" + annotation = file_to_annotation(file_path, rm_unknown=True) + return annotation.get_timeline(copy=False).duration() + + +def get_data_set_results( + reference_dbl: str, + hypothesis_dbl: str, + dbl_root: Optional[str] = os.getcwd(), + seg_tolerance=DEFAULT_SEGMENT_TOLERANCE, + allow_none_hyp_lab: bool = False, +) -> dict: + """Takes as input two DBL files describing the list of corresponding reference + and hypothesis files. Returns a dictionary containing results for different + diarisation metrics""" + overall_results = {} + file_results = [] + references = utils.load_dbl(reference_dbl) + hypotheses = utils.load_dbl(hypothesis_dbl) + + weighted_diarisation_error_rates = [] + weighted_der_insertion = [] + weighted_der_deletion = [] + weighted_der_confusion = [] + weighted_jaccard_error_rates = [] + weighted_diarisation_purities = [] + weighted_diarisation_coverage = [] + weighted_segmentation_coverage = [] + weighted_segmentation_purity = [] + weighted_segmentation_precision = [] + weighted_segmentation_recall = [] + weighted_segmentation_f1 = [] + weighted_word_der = [] + speaker_uu_percentages = [] + total_audio_duration = 0 + total_ref_duration = 0 + total_hyp_duration = 0 + total_nwords = 0 + total_nfiles = len(references) + total_ref_speakers = 0 + total_hyp_speakers = 0 + total_files_nspeakers_correct = 0 + total_files_nspeakers_plus_one = 0 + total_files_nspeakers_plus_many = 0 + total_files_nspeakers_minus_one = 0 + total_files_nspeakers_minus_many = 0 + total_files_single_speaker_issue = 0 + total_speaker_discrepancy = 0 + + for (i, (ref, hyp)) in enumerate(zip(references, hypotheses)): + logger.debug("Computing results for files: ref=%s, hyp=%s. Progress: %d/%d", ref, hyp, i + 1, len(references)) + + if dbl_root is not None: + ref_path = os.path.join(dbl_root, ref) + hyp_path = os.path.join(dbl_root, hyp) + else: + ref_path = ref + hyp_path = hyp + + # Reference duration, used to weight results + audio_duration = get_diarisation_file_duration_seconds(ref_path) + ref_duration = get_diarisation_labelled_duration_seconds(ref_path) + hyp_duration = get_diarisation_labelled_duration_seconds(hyp_path) + total_audio_duration += audio_duration + total_ref_duration += ref_duration + total_hyp_duration += hyp_duration + + if not os.path.isfile(hyp_path): # current VAD doesn't give output for some telephony data in RT mode + if hyp_path.endswith(".lab"): + if allow_none_hyp_lab: + with open(hyp_path, "w") as fake_hyp: + fake_hyp.write(f"0.000 {str(audio_duration)} {UNKNOWN_SPEAKER}\n") + else: + raise ValueError( + f"Hypothesis lab does not exist: {hyp_path}, use --allow-none-hyp-lab for creating dummy lab" + ) + elif hyp_path.endswith(".json"): + raise ValueError(f"Hypothesis json does not exist: {hyp_path}") + + # DER and related metrics + der, insertion, deletion, confusion = get_der_component_details_for_files(ref_path, hyp_path) + weighted_diarisation_error_rates.append(der * ref_duration) + weighted_der_insertion.append(insertion * ref_duration) + weighted_der_deletion.append(deletion * ref_duration) + weighted_der_confusion.append(confusion * ref_duration) + + # Further speaker diarization metrics + diarisation_purity = get_purity_for_files(ref_path, hyp_path) + diarisation_coverage = get_coverage_for_files(ref_path, hyp_path) + weighted_diarisation_purities.append(diarisation_purity * ref_duration) + weighted_diarisation_coverage.append(diarisation_coverage * ref_duration) + + # Jaccard error rate and related metrics + jaccard_error_rate = get_jaccard_error_rate_for_files(ref_path, hyp_path) + weighted_jaccard_error_rates.append(jaccard_error_rate * ref_duration) + + # Speaker change metrics + seg_purity, seg_coverage, seg_precision, seg_recall = get_segmentation_metrics_for_files( + ref_path, hyp_path, tolerance=seg_tolerance + ) + seg_f1_score = f1_score(seg_precision, seg_recall) + weighted_segmentation_purity.append(seg_purity * ref_duration) + weighted_segmentation_coverage.append(seg_coverage * ref_duration) + weighted_segmentation_precision.append(seg_precision * ref_duration) + weighted_segmentation_recall.append(seg_recall * ref_duration) + weighted_segmentation_f1.append(seg_f1_score * ref_duration) + + # Word level DER + word_der, nwords, _, speaker_uu_percentage = get_word_level_metrics_for_files(ref_path, hyp_path) + weighted_word_der.append(word_der * nwords) + speaker_uu_percentages.append(speaker_uu_percentage) + total_nwords += nwords + + # Speaker counts + ref_speakers, hyp_speakers = get_speaker_count_metrics_for_files(ref_path, hyp_path) + total_ref_speakers += ref_speakers + total_hyp_speakers += hyp_speakers + total_speaker_discrepancy += abs(ref_speakers - hyp_speakers) + rate_nspeakers_correct = 0.0 + rate_nspeakers_plus_one = 0.0 + rate_nspeakers_plus_many = 0.0 + rate_nspeakers_minus_one = 0.0 + rate_nspeakers_minus_many = 0.0 + rate_single_speaker_issue = 0.0 + if ref_speakers == hyp_speakers: + total_files_nspeakers_correct += 1 + rate_nspeakers_correct = 1.0 + elif hyp_speakers > ref_speakers: + if (hyp_speakers - ref_speakers) == 1: + total_files_nspeakers_plus_one += 1 + rate_nspeakers_plus_one = 1.0 + else: + total_files_nspeakers_plus_many += 1 + rate_nspeakers_plus_many = 1.0 + else: + if hyp_speakers == 1: + total_files_single_speaker_issue += 1 + rate_single_speaker_issue = 1.0 + if (ref_speakers - hyp_speakers) == 1: + total_files_nspeakers_minus_one += 1 + rate_nspeakers_minus_one = 1.0 + else: + total_files_nspeakers_minus_many += 1 + rate_nspeakers_minus_many = 1.0 + + nspeakers_discrepancy = hyp_speakers - ref_speakers + + # Store the results for this particular file + file_result = {} + file_result["reference"] = ref_path + file_result["hypothesis"] = hyp_path + file_result["audio_duration"] = audio_duration + file_result["ref_duration"] = ref_duration + file_result["hyp_duration"] = hyp_duration + file_result["audio_labelled"] = hyp_duration / audio_duration + file_result["ref_labelled"] = hyp_duration / ref_duration + file_result["der"] = der + file_result["insertion"] = insertion + file_result["deletion"] = deletion + file_result["conf"] = confusion + file_result["purity"] = diarisation_purity + file_result["coverage"] = diarisation_coverage + file_result["jer"] = jaccard_error_rate + file_result["seg_purity"] = seg_purity + file_result["seg_coverage"] = seg_coverage + file_result["seg_precision"] = seg_precision + file_result["seg_recall"] = seg_recall + file_result["seg_f1_score"] = seg_f1_score + file_result["word_der"] = word_der + file_result["speaker_uu_percentage"] = speaker_uu_percentage + file_result["ref_speakers"] = ref_speakers + file_result["hyp_speakers"] = hyp_speakers + file_result["nspeakers_discrepancy"] = nspeakers_discrepancy + file_result["abs_nspeakers_discrepancy"] = abs(nspeakers_discrepancy) + file_result["rate_nspeakers_correct"] = rate_nspeakers_correct + file_result["rate_nspeakers_plus_one"] = rate_nspeakers_plus_one + file_result["rate_nspeakers_plus_many"] = rate_nspeakers_plus_many + file_result["rate_nspeakers_minus_one"] = rate_nspeakers_minus_one + file_result["rate_nspeakers_minus_many"] = rate_nspeakers_minus_many + file_result["rate_single_speaker_issue"] = rate_single_speaker_issue + file_results.append(file_result) + + # Compute averages across set + if total_nwords > 0: + average_word_der = sum(weighted_word_der) / total_nwords + average_speaker_uu_percentage = sum(speaker_uu_percentages) / len(speaker_uu_percentages) + else: + average_word_der = 0.0 + average_speaker_uu_percentage = 0 + + overall_results["total_audio_duration"] = total_audio_duration + overall_results["total_ref_duration"] = total_ref_duration + overall_results["total_hyp_duration"] = total_hyp_duration + overall_results["audio_labelled"] = total_hyp_duration / total_audio_duration + overall_results["ref_labelled"] = total_hyp_duration / total_ref_duration + overall_results["total_nwords"] = total_nwords + + overall_results["average_der"] = sum(weighted_diarisation_error_rates) / total_ref_duration + overall_results["average_jer"] = sum(weighted_jaccard_error_rates) / total_ref_duration + overall_results["average_insertion"] = sum(weighted_der_insertion) / total_ref_duration + overall_results["average_deletion"] = sum(weighted_der_deletion) / total_ref_duration + overall_results["average_confusion"] = sum(weighted_der_confusion) / total_ref_duration + + overall_results["average_diarisation_coverage"] = sum(weighted_diarisation_coverage) / total_ref_duration + overall_results["average_diarisation_purity"] = sum(weighted_diarisation_purities) / total_ref_duration + + overall_results["average_segmentation_coverage"] = sum(weighted_segmentation_coverage) / total_ref_duration + overall_results["average_segmentation_purity"] = sum(weighted_segmentation_purity) / total_ref_duration + overall_results["average_segmentation_precision"] = sum(weighted_segmentation_precision) / total_ref_duration + overall_results["average_segmentation_recall"] = sum(weighted_segmentation_recall) / total_ref_duration + overall_results["average_segmentation_f1"] = sum(weighted_segmentation_f1) / total_ref_duration + + overall_results["average_word_der"] = average_word_der + overall_results["average_speaker_uu_percentage"] = average_speaker_uu_percentage + + # Speaker count statistics + if total_nfiles > 0: + avg_nspeakers_ref = total_ref_speakers / total_nfiles + avg_nspeakers_hyp = total_hyp_speakers / total_nfiles + nspeakers_correct_rate = total_files_nspeakers_correct / total_nfiles + nspeakers_plus_one_rate = total_files_nspeakers_plus_one / total_nfiles + nspeakers_plus_many_rate = total_files_nspeakers_plus_many / total_nfiles + nspeakers_minus_one_rate = total_files_nspeakers_minus_one / total_nfiles + nspeakers_minus_many_rate = total_files_nspeakers_minus_many / total_nfiles + single_speaker_issue_rate = total_files_single_speaker_issue / total_nfiles + overall_results["average_nspeakers_ref"] = avg_nspeakers_ref + overall_results["average_nspeakers_hyp"] = avg_nspeakers_hyp + overall_results["average_nspeakers_discrepancy"] = avg_nspeakers_hyp - avg_nspeakers_ref + overall_results["average_nspeakers_abs_discrepancy"] = total_speaker_discrepancy / total_nfiles + overall_results["rate_nspeakers_correct"] = nspeakers_correct_rate + overall_results["rate_nspeakers_plus_one"] = nspeakers_plus_one_rate + overall_results["rate_nspeakers_plus_many"] = nspeakers_plus_many_rate + overall_results["rate_nspeakers_minus_one"] = nspeakers_minus_one_rate + overall_results["rate_nspeakers_minus_many"] = nspeakers_minus_many_rate + overall_results["rate_single_speaker_issue"] = single_speaker_issue_rate + else: + overall_results["average_nspeakers_ref"] = 0.0 + overall_results["average_nspeakers_hyp"] = 0.0 + overall_results["average_nspeakers_discrepancy"] = 0.0 + overall_results["average_nspeakers_abs_discrepancy"] = 0.0 + overall_results["rate_nspeakers_correct"] = 0.0 + overall_results["rate_nspeakers_plus_one"] = 0.0 + overall_results["rate_nspeakers_plus_many"] = 0.0 + overall_results["rate_nspeakers_minus_one"] = 0.0 + overall_results["rate_nspeakers_minus_many"] = 0.0 + overall_results["rate_single_speaker_issue"] = 0.0 + + return overall_results, file_results + + +def output_results_as_json(parameters: dict, overall_results: dict, file_results: list, outdir: str): + """Takes in the results data and output as a json file""" + final_json_output = {} + final_json_output["args"] = parameters + final_json_output["files"] = file_results + final_json_output["overall"] = overall_results + json_output_path = os.path.join(outdir, "results.json") + with open(json_output_path, "w") as result_json_file: + json.dump(final_json_output, result_json_file) + + +def output_results_as_csv(overall_results: dict, file_results: list, overall_csv: str, details_csv: str): + """Takes in the results data and output as a csv file""" + + # Write the results for each file (including header) + if len(file_results) == 0: + raise RuntimeError("No file results found, so aborting CSV output") + header = str(list(file_results[0].keys())).replace("]", "").replace("[", "").replace("'", "") + with open(details_csv, "w") as cfh: + print(header, file=cfh) + for file_result in file_results: + values = str(list(file_result.values())).replace("]", "").replace("[", "").replace("'", "") + print(values, file=cfh) + + # Now write the overall results + header = str(list(overall_results.keys())).replace("]", "").replace("[", "").replace("'", "") + with open(overall_csv, "w") as cfh: + print(header, file=cfh) + values = str(list(overall_results.values())).replace("]", "").replace("[", "").replace("'", "") + print(values, file=cfh) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "reference_file", + type=str, + help=( + "A file describing the true diarisation of some audio." + " Several formats are supported including 'dbl', 'ctm', 'lab' and" + " Speechmatics V2 'json'" + ), + ) + parser.add_argument( + "hypothesis_file", + type=str, + help=( + "A file describing the hypothesised diarisation of some audio." + " Several formats are supported including 'dbl', 'ctm', 'lab' and" + " Speechmatics V2 'json'" + ), + ) + parser.add_argument( + "--dbl-root", + type=str, + default=os.getcwd(), + help=("If using DBL input then this argument specifies the root directory " "for files listed in the DBL."), + ) + parser.add_argument( + "--output-format", + type=str, + default="json", + help=("Output mertics scores for data set and each file in a certain format" "can choose between json or csv"), + ) + parser.add_argument( + "--segmentation-tolerance", + type=float, + default=DEFAULT_SEGMENT_TOLERANCE, + help=("Tolerance in seconds when matching hypothesised change point gwith that in the reference (in seconds)"), + ) + parser.add_argument( + "--show-words", + action="store_true", + default=False, + help=("Show the words (if using json hypothesis) alongside whether correct or not."), + ) + parser.add_argument( + "--output-hyp-label", + type=str, + default=None, + help=("Output hypothesis label file (for single file pair only, not DBL)."), + ) + parser.add_argument( + "--allow-none-hyp-lab", + type=str, + default=False, + help=("If missing, create a dummy hypothesis lab file with all speakers set to 'UU'"), + ) + parser.add_argument( + "--debug", + action="store_true", + help=("Enable debugging information."), + ) + + parser.add_argument("--outdir", type=str, default=None, help=("Output directory (OPTIONAL).")) + args = parser.parse_args() + + log_level = logging.DEBUG if args.debug else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s %(message)s", + handlers=[logging.StreamHandler()], + ) + + assert os.path.isfile(args.reference_file) + assert os.path.isfile(args.hypothesis_file) + + if args.outdir is not None: + outdir = args.outdir + else: + outdir = "/".join(args.hypothesis_file.split("/")[:-1]) + + reference_file_extension = args.reference_file.split(".")[-1] + hypothesis_file_extension = args.reference_file.split(".")[-1] + + if "dbl" in [reference_file_extension, hypothesis_file_extension]: + # Process over a set of files (pairs of hypothesis / reference) + if not (reference_file_extension == "dbl" and hypothesis_file_extension == "dbl"): + raise ValueError("If using DBL input then both files must be DBLs") + + overall_results, file_results = get_data_set_results( + args.reference_file, + args.hypothesis_file, + dbl_root=args.dbl_root, + seg_tolerance=args.segmentation_tolerance, + allow_none_hyp_lab=args.allow_none_hyp_lab, + ) + + if args.output_format == "json": + parameters = {} + parameters["reference_file"] = args.reference_file + parameters["hypothesis_file"] = args.hypothesis_file + parameters["--dbl-root"] = args.dbl_root + output_results_as_json(parameters, overall_results, file_results, outdir) + + if args.output_format == "csv": + details_csv = os.path.join(outdir, "results-details.csv") + overall_csv = os.path.join(outdir, "results-summary.csv") + output_results_as_csv(overall_results, file_results, overall_csv, details_csv) + else: + # Compute metrics on a single hypothesis / reference pair + audio_duration = get_diarisation_file_duration_seconds(args.reference_file) + ref_duration = get_diarisation_labelled_duration_seconds(args.reference_file) + hyp_duration = get_diarisation_labelled_duration_seconds(args.hypothesis_file) + audio_labelled = hyp_duration / audio_duration + ref_labelled = hyp_duration / ref_duration + + der, insertion, deletion, confusion = get_der_component_details_for_files( + args.reference_file, args.hypothesis_file + ) + diarization_coverage = get_coverage_for_files(args.reference_file, args.hypothesis_file) + diarization_purity = get_purity_for_files(args.reference_file, args.hypothesis_file) + jaccard_error_rate = get_jaccard_error_rate_for_files(args.reference_file, args.hypothesis_file) + segment_purity, segment_coverage, segment_precision, segment_recall = get_segmentation_metrics_for_files( + args.reference_file, args.hypothesis_file, tolerance=args.segmentation_tolerance + ) + segment_F1_score = f1_score(segment_precision, segment_recall) + word_der, nwords, words, speaker_uu_percentage = get_word_level_metrics_for_files( + args.reference_file, args.hypothesis_file + ) + nspeakers_reference, nspeakers_hypothesis = get_speaker_count_metrics_for_files( + args.reference_file, args.hypothesis_file + ) + + # Output hypothesis as label if required + if args.output_hyp_label is not None: + annotation = file_to_annotation(args.hypothesis_file) + write_annotation_to_label_file(annotation, args.output_hyp_label) + print("Wrote hypothesis label file: {}".format(args.output_hyp_label)) + + # Show the word level error information if required + if args.show_words: + print_word_der_details(words) + + # Show the summary of metrics (for single file) + print("--------------------------------") + print("Audio Duration (s): {:.3f}s".format(audio_duration)) + print("Reference Labelled (s) {:.3f}s".format(ref_duration)) + print("Hypothesis Labelled (s) {:.3f}s".format(hyp_duration)) + print("Audio labelled: {:.3f}".format(audio_labelled)) + print("Ref labelled: {:.3f}".format(ref_labelled)) + print("--------------------------------") + print("DER: {:.3f}".format(der)) + print("Insertion: {:.3f}".format(insertion)) + print("Deletion: {:.3f}".format(deletion)) + print("Confusion: {:.3f}".format(confusion)) + print("--------------------------------") + print("Diarization Coverage: {:.3f}".format(diarization_coverage)) + print("Diarization Purity: {:.3f}".format(diarization_purity)) + print("--------------------------------") + print("Jaccard Error Rate: {:.3f}".format(jaccard_error_rate)) + print("--------------------------------") + print("Segmentation Coverage: {:.3f}".format(segment_purity)) + print("Segmentation Purity: {:.3f}".format(segment_coverage)) + print("Segmentation Precision: {:.3f}".format(segment_precision)) + print("Segmentation Recall: {:.3f}".format(segment_recall)) + print("Segmentation F1 Score: {:.3f}".format(segment_F1_score)) + print("--------------------------------") + print("Word level DER: {:.3f}".format(word_der)) + print("Speaker UU percentage: {:.3f}".format(speaker_uu_percentage)) + print("--------------------------------") + print("NSpeakers Reference: {}".format(nspeakers_reference)) + print("NSpeakers Hypothesis: {}".format(nspeakers_hypothesis)) + print("NSpeakers Discrepancy: {}".format(nspeakers_hypothesis - nspeakers_reference)) + print("--------------------------------") + + +if __name__ == "__main__": + main() diff --git a/metrics/diarization/sm_diarization_metrics/metrics/__init__.py b/metrics/diarization/sm_diarization_metrics/metrics/__init__.py new file mode 100644 index 0000000..a2ce89e --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +from ._version import get_versions +from .base import f_measure + +__version__ = get_versions()["version"] +del get_versions + + +__all__ = ["f_measure"] diff --git a/metrics/diarization/sm_diarization_metrics/metrics/_version.py b/metrics/diarization/sm_diarization_metrics/metrics/_version.py new file mode 100644 index 0000000..c30a3c2 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/_version.py @@ -0,0 +1,450 @@ +# This file helps to compute a version number in source trees obtained from +# git-archive tarball (such as those provided by githubs download-from-tag +# feature). Distribution tarballs (built by setup.py sdist) and build +# directories (produced by setup.py build) will contain a much shorter file +# that just contains the computed version number. + +# This file is released into the public domain. Generated by +# versioneer-0.15 (https://github.com/warner/python-versioneer) + +import errno +import os +import re +import subprocess +import sys + + +def get_keywords(): + # these strings will be replaced by git during git-archive. + # setup.py/versioneer.py will grep for the variable names, so they must + # each be defined on a line of their own. _version.py will just call + # get_keywords(). + git_refnames = "$Format:%d$" + git_full = "$Format:%H$" + keywords = {"refnames": git_refnames, "full": git_full} + return keywords + + +class VersioneerConfig: + pass + + +def get_config(): + # these strings are filled in when 'setup.py versioneer' creates + # _version.py + cfg = VersioneerConfig() + cfg.VCS = "git" + cfg.style = "pep440" + cfg.tag_prefix = "" + cfg.parentdir_prefix = "pyannote-metrics-" + cfg.versionfile_source = "pyannote/metrics/_version.py" + cfg.verbose = False + return cfg + + +class NotThisMethod(Exception): + pass + + +LONG_VERSION_PY = {} +HANDLERS = {} + + +def register_vcs_handler(vcs, method): # decorator + def decorate(f): + if vcs not in HANDLERS: + HANDLERS[vcs] = {} + HANDLERS[vcs][method] = f + return f + + return decorate + + +def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): + assert isinstance(commands, list) + p = None + for c in commands: + dispcmd = str([c] + args) + try: + # remember shell=False, so use git.cmd on windows, not just git + p = subprocess.Popen( + [c] + args, cwd=cwd, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + ) + break + except EnvironmentError: + e = sys.exc_info()[1] + if e.errno == errno.ENOENT: + continue + if verbose: + print("unable to run the following command: %s" % dispcmd) + print(e) + return None + else: + if verbose: + print("unable to find command, tried %s" % (commands,)) + return None + stdout = p.communicate()[0].strip() + if sys.version_info[0] >= 3: + stdout = stdout.decode() + if p.returncode != 0: + if verbose: + print("unable to run one of the following commands (error): %s" % (commands,)) + return None + return stdout + + +def versions_from_parentdir(parentdir_prefix, root, verbose): + # Source tarballs conventionally unpack into a directory that includes + # both the project name and a version string. + dirname = os.path.basename(root) + if not dirname.startswith(parentdir_prefix): + if verbose: + print( + "guessing rootdir is '%s', but '%s' doesn't start with " + "prefix '%s'" % (root, dirname, parentdir_prefix) + ) + raise NotThisMethod("rootdir doesn't start with parentdir_prefix") + return {"version": dirname[len(parentdir_prefix) :], "full-revisionid": None, "dirty": False, "error": None} + + +@register_vcs_handler("git", "get_keywords") +def git_get_keywords(versionfile_abs): + # the code embedded in _version.py can just fetch the value of these + # keywords. When used from setup.py, we don't want to import _version.py, + # so we do it with a regexp instead. This function is not used from + # _version.py. + keywords = {} + try: + f = open(versionfile_abs, "r") + for line in f.readlines(): + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + f.close() + except EnvironmentError: + pass + return keywords + + +@register_vcs_handler("git", "keywords") +def git_versions_from_keywords(keywords, tag_prefix, verbose): + if not keywords: + raise NotThisMethod("no keywords at all, weird") + refnames = keywords["refnames"].strip() + if refnames.startswith("$Format"): + if verbose: + print("keywords are unexpanded, not using") + raise NotThisMethod("unexpanded keywords, not a git-archive tarball") + refs = set([r.strip() for r in refnames.strip("()").split(",")]) + # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of + # just "foo-1.0". If we see a "tag: " prefix, prefer those. + TAG = "tag: " + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + if not tags: + # Either we're using git < 1.8.3, or there really are no tags. We use + # a heuristic: assume all version tags have a digit. The old git %d + # expansion behaves like git log --decorate=short and strips out the + # refs/heads/ and refs/tags/ prefixes that would let us distinguish + # between branches and tags. By ignoring refnames without digits, we + # filter out many common branch names like "release" and + # "stabilization", as well as "HEAD" and "master". + tags = set([r for r in refs if re.search(r"\d", r)]) + if verbose: + print("discarding '%s', no digits" % ",".join(refs - tags)) + if verbose: + print("likely tags: %s" % ",".join(sorted(tags))) + for ref in sorted(tags): + # sorting will prefer e.g. "2.0" over "2.0rc1" + if ref.startswith(tag_prefix): + r = ref[len(tag_prefix) :] + if verbose: + print("picking %s" % r) + return {"version": r, "full-revisionid": keywords["full"].strip(), "dirty": False, "error": None} + # no suitable tags, so version is "0+unknown", but full hex is still there + if verbose: + print("no suitable tags, using unknown + full revision id") + return { + "version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": "no suitable tags", + } + + +@register_vcs_handler("git", "pieces_from_vcs") +def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): + # this runs 'git' from the root of the source tree. This only gets called + # if the git-archive 'subst' keywords were *not* expanded, and + # _version.py hasn't already been rewritten with a short version string, + # meaning we're inside a checked out source tree. + + if not os.path.exists(os.path.join(root, ".git")): + if verbose: + print("no .git in %s" % root) + raise NotThisMethod("no .git directory") + + GITS = ["git"] + if sys.platform == "win32": + GITS = ["git.cmd", "git.exe"] + # if there is a tag, this yields TAG-NUM-gHEX[-dirty] + # if there are no tags, this yields HEX[-dirty] (no NUM) + describe_out = run_command(GITS, ["describe", "--tags", "--dirty", "--always", "--long"], cwd=root) + # --long was added in git-1.5.5 + if describe_out is None: + raise NotThisMethod("'git describe' failed") + describe_out = describe_out.strip() + full_out = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + if full_out is None: + raise NotThisMethod("'git rev-parse' failed") + full_out = full_out.strip() + + pieces = {} + pieces["long"] = full_out + pieces["short"] = full_out[:7] # maybe improved later + pieces["error"] = None + + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] + # TAG might have hyphens. + git_describe = describe_out + + # look for -dirty suffix + dirty = git_describe.endswith("-dirty") + pieces["dirty"] = dirty + if dirty: + git_describe = git_describe[: git_describe.rindex("-dirty")] + + # now we have TAG-NUM-gHEX or HEX + + if "-" in git_describe: + # TAG-NUM-gHEX + mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + if not mo: + # unparseable. Maybe git-describe is misbehaving? + pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + return pieces + + # tag + full_tag = mo.group(1) + if not full_tag.startswith(tag_prefix): + if verbose: + fmt = "tag '%s' doesn't start with prefix '%s'" + print(fmt % (full_tag, tag_prefix)) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + return pieces + pieces["closest-tag"] = full_tag[len(tag_prefix) :] + + # distance: number of commits since tag + pieces["distance"] = int(mo.group(2)) + + # commit: short hex revision ID + pieces["short"] = mo.group(3) + + else: + # HEX: no tags + pieces["closest-tag"] = None + count_out = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) + pieces["distance"] = int(count_out) # total number of commits + + return pieces + + +def plus_or_dot(pieces): + if "+" in pieces.get("closest-tag", ""): + return "." + return "+" + + +def render_pep440(pieces): + # now build up version string, with post-release "local version + # identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you + # get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty + + # exceptions: + # 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_pre(pieces): + # TAG[.post.devDISTANCE] . No -dirty + + # exceptions: + # 1: no tags. 0.post.devDISTANCE + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"]: + rendered += ".post.dev%d" % pieces["distance"] + else: + # exception #1 + rendered = "0.post.dev%d" % pieces["distance"] + return rendered + + +def render_pep440_post(pieces): + # TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that + # .dev0 sorts backwards (a dirty tree will appear "older" than the + # corresponding clean one), but you shouldn't be releasing software with + # -dirty anyways. + + # exceptions: + # 1: no tags. 0.postDISTANCE[.dev0] + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + return rendered + + +def render_pep440_old(pieces): + # TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. + + # exceptions: + # 1: no tags. 0.postDISTANCE[.dev0] + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["dirty"]: + rendered += ".dev0" + return rendered + + +def render_git_describe(pieces): + # TAG[-DISTANCE-gHEX][-dirty], like 'git describe --tags --dirty + # --always' + + # exceptions: + # 1: no tags. HEX[-dirty] (note: no 'g' prefix) + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"]: + rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) + else: + # exception #1 + rendered = pieces["short"] + if pieces["dirty"]: + rendered += "-dirty" + return rendered + + +def render_git_describe_long(pieces): + # TAG-DISTANCE-gHEX[-dirty], like 'git describe --tags --dirty + # --always -long'. The distance/hash is unconditional. + + # exceptions: + # 1: no tags. HEX[-dirty] (note: no 'g' prefix) + + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) + else: + # exception #1 + rendered = pieces["short"] + if pieces["dirty"]: + rendered += "-dirty" + return rendered + + +def render(pieces, style): + if pieces["error"]: + return {"version": "unknown", "full-revisionid": pieces.get("long"), "dirty": None, "error": pieces["error"]} + + if not style or style == "default": + style = "pep440" # the default + + if style == "pep440": + rendered = render_pep440(pieces) + elif style == "pep440-pre": + rendered = render_pep440_pre(pieces) + elif style == "pep440-post": + rendered = render_pep440_post(pieces) + elif style == "pep440-old": + rendered = render_pep440_old(pieces) + elif style == "git-describe": + rendered = render_git_describe(pieces) + elif style == "git-describe-long": + rendered = render_git_describe_long(pieces) + else: + raise ValueError("unknown style '%s'" % style) + + return {"version": rendered, "full-revisionid": pieces["long"], "dirty": pieces["dirty"], "error": None} + + +def get_versions(): + # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have + # __file__, we can work backwards from there to the root. Some + # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which + # case we can only use expanded keywords. + + cfg = get_config() + verbose = cfg.verbose + + try: + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) + except NotThisMethod: + pass + + try: + root = os.path.realpath(__file__) + # versionfile_source is the relative path from the top of the source + # tree (where the .git directory might live) to this file. Invert + # this to find the root from __file__. + for i in cfg.versionfile_source.split("/"): + root = os.path.dirname(root) + except NameError: + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + } + + try: + pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) + return render(pieces, cfg.style) + except NotThisMethod: + pass + + try: + if cfg.parentdir_prefix: + return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) + except NotThisMethod: + pass + + return {"version": "0+unknown", "full-revisionid": None, "dirty": None, "error": "unable to compute version"} diff --git a/metrics/diarization/sm_diarization_metrics/metrics/base.py b/metrics/diarization/sm_diarization_metrics/metrics/base.py new file mode 100755 index 0000000..80352c6 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/base.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + + +import numpy as np +import pandas as pd +import scipy.stats + + +class BaseMetric(object): + """ + :class:`BaseMetric` is the base class for most pyannote evaluation metrics. + + Parameters + ---------- + parallel : bool, optional + Defaults to True + + Attributes + ---------- + name : str + Human-readable name of the metric (eg. 'diarization error rate') + """ + + @classmethod + def metric_name(cls): + raise NotImplementedError( + cls.__name__ + " is missing a 'metric_name' class method. " + "It should return the name of the metric as string." + ) + + @classmethod + def metric_components(cls): + raise NotImplementedError( + cls.__name__ + " is missing a 'metric_components' class method. " + "It should return the list of names of metric components." + ) + + def __init__(self, parallel=False, **kwargs): + super(BaseMetric, self).__init__() + if parallel: + raise NotImplementedError("Parallel running not implemented") + self.metric_name_ = self.__class__.metric_name() + self.components_ = set(self.__class__.metric_components()) + self.reset() + + def init_components(self): + return {value: 0.0 for value in self.components_} + + def reset(self): + """Reset accumulated components and metric values""" + self.accumulated_ = dict() + self.results_ = list() + self.uris_ = dict() + for value in self.components_: + self.accumulated_[value] = 0.0 + + def __get_name(self): + return self.__class__.metric_name() + + name = property(fget=__get_name, doc="Metric name.") + + def __call__(self, reference, hypothesis, detailed=False, **kwargs): + """Compute metric value and accumulate components + + Parameters + ---------- + reference : type depends on the metric + Manual `reference` + hypothesis : same as `reference` + Evaluated `hypothesis` + detailed : bool, optional + By default (False), return metric value only. + + Set `detailed` to True to return dictionary where keys are + components names and values are component values + + Returns + ------- + value : float (if `detailed` is False) + Metric value + components : dict (if `detailed` is True) + `components` updated with metric value + + """ + + # compute metric components + components = self.compute_components(reference, hypothesis, **kwargs) + + # compute rate based on components + components[self.metric_name_] = self.compute_metric(components) + + # keep track of this computation + uri = reference.uri + if uri is None: + uri = "???" + if uri not in self.uris_: + self.uris_[uri] = 1 + else: + self.uris_[uri] += 1 + uri = uri + " #{0:d}".format(self.uris_[uri]) + + self.results_.append((uri, components)) + + # accumulate components + for name in self.components_: + self.accumulated_[name] += components[name] + + if detailed: + return components + + return components[self.metric_name_] + + def report(self, display=False): + """Evaluation report + + Parameters + ---------- + display : bool, optional + Set to True to print the report to stdout. + + Returns + ------- + report : pandas.DataFrame + Dataframe with one column per metric component, one row per + evaluated item, and one final row for accumulated results. + """ + + report = [] + uris = [] + + percent = "total" in self.metric_components() + + for uri, components in self.results_: + row = {} + if percent: + total = components["total"] + for key, value in components.items(): + if key == self.name: + row[key, "%"] = 100 * value + elif key == "total": + row[key, ""] = value + else: + row[key, ""] = value + if percent: + if total > 0: + row[key, "%"] = 100 * value / total + else: + row[key, "%"] = np.NaN + + report.append(row) + uris.append(uri) + + row = {} + components = self.accumulated_ + + if percent: + total = components["total"] + + for key, value in components.items(): + if key == self.name: + row[key, "%"] = 100 * value + elif key == "total": + row[key, ""] = value + else: + row[key, ""] = value + if percent: + if total > 0: + row[key, "%"] = 100 * value / total + else: + row[key, "%"] = np.NaN + + row[self.name, "%"] = 100 * abs(self) + report.append(row) + uris.append("TOTAL") + + df = pd.DataFrame(report) + + df["item"] = uris + df = df.set_index("item") + + df.columns = pd.MultiIndex.from_tuples(df.columns) + + df = df[[self.name] + self.metric_components()] + + if display: + print(df.to_string(index=True, sparsify=False, justify="right", float_format=lambda f: "{0:.2f}".format(f))) + + return df + + def __str__(self): + report = self.report(display=False) + return report.to_string(sparsify=False, float_format=lambda f: "{0:.2f}".format(f)) + + def __abs__(self): + """Compute metric value from accumulated components""" + return self.compute_metric(self.accumulated_) + + def __getitem__(self, component): + """Get value of accumulated `component`. + + Parameters + ---------- + component : str + Name of `component` + + Returns + ------- + value : type depends on the metric + Value of accumulated `component` + + """ + if component == slice(None, None, None): + return dict(self.accumulated_) + else: + return self.accumulated_[component] + + def __iter__(self): + """Iterator over the accumulated (uri, value)""" + for uri, component in self.results_: + yield uri, component + + def compute_components(self, reference, hypothesis, **kwargs): + """Compute metric components + + Parameters + ---------- + reference : type depends on the metric + Manual `reference` + hypothesis : same as `reference` + Evaluated `hypothesis` + + Returns + ------- + components : dict + Dictionary where keys are component names and values are component + values + + """ + raise NotImplementedError( + self.__class__.__name__ + " is missing a 'compute_components' method." + "It should return a dictionary where keys are component names " + "and values are component values." + ) + + def compute_metric(self, components): + """Compute metric value from computed `components` + + Parameters + ---------- + components : dict + Dictionary where keys are components names and values are component + values + + Returns + ------- + value : type depends on the metric + Metric value + """ + raise NotImplementedError( + self.__class__.__name__ + " is missing a 'compute_metric' method. " + "It should return the actual value of the metric based " + "on the precomputed component dictionary given as input." + ) + + def confidence_interval(self, alpha=0.9): + """Compute confidence interval on accumulated metric values + + Parameters + ---------- + alpha : float, optional + Probability that the returned confidence interval contains + the true metric value. + + Returns + ------- + (center, (lower, upper)) + with center the mean of the conditional pdf of the metric value + and (lower, upper) is a confidence interval centered on the median, + containing the estimate to a probability alpha. + + See Also: + --------- + scipy.stats.bayes_mvs + + """ + m, _, _ = scipy.stats.bayes_mvs([r[self.metric_name_] for _, r in self.results_], alpha=alpha) + return m + + +PRECISION_NAME = "precision" +PRECISION_RETRIEVED = "# retrieved" +PRECISION_RELEVANT_RETRIEVED = "# relevant retrieved" + + +class Precision(BaseMetric): + """ + :class:`Precision` is a base class for precision-like evaluation metrics. + + It defines two components '# retrieved' and '# relevant retrieved' and the + compute_metric() method to compute the actual precision: + + Precision = # retrieved / # relevant retrieved + + Inheriting classes must implement compute_components(). + """ + + @classmethod + def metric_name(cls): + return PRECISION_NAME + + @classmethod + def metric_components(cls): + return [PRECISION_RETRIEVED, PRECISION_RELEVANT_RETRIEVED] + + def compute_metric(self, components): + """Compute precision from `components`""" + numerator = components[PRECISION_RELEVANT_RETRIEVED] + denominator = components[PRECISION_RETRIEVED] + if denominator == 0.0: + if numerator == 0: + return 1.0 + else: + raise ValueError("") + else: + return numerator / denominator + + +RECALL_NAME = "recall" +RECALL_RELEVANT = "# relevant" +RECALL_RELEVANT_RETRIEVED = "# relevant retrieved" + + +class Recall(BaseMetric): + """ + :class:`Recall` is a base class for recall-like evaluation metrics. + + It defines two components '# relevant' and '# relevant retrieved' and the + compute_metric() method to compute the actual recall: + + Recall = # relevant retrieved / # relevant + + Inheriting classes must implement compute_components(). + """ + + @classmethod + def metric_name(cls): + return RECALL_NAME + + @classmethod + def metric_components(cls): + return [RECALL_RELEVANT, RECALL_RELEVANT_RETRIEVED] + + def compute_metric(self, components): + """Compute recall from `components`""" + numerator = components[RECALL_RELEVANT_RETRIEVED] + denominator = components[RECALL_RELEVANT] + if denominator == 0.0: + if numerator == 0: + return 1.0 + else: + raise ValueError("") + else: + return numerator / denominator + + +def f_measure(precision, recall, beta=1.0): + """Compute f-measure + + f-measure is defined as follows: + F(P, R, b) = (1+b²).P.R / (b².P + R) + + where P is `precision`, R is `recall` and b is `beta` + """ + if precision + recall == 0.0: + return 0 + return (1 + beta * beta) * precision * recall / (beta * beta * precision + recall) diff --git a/metrics/diarization/sm_diarization_metrics/metrics/binary_classification.py b/metrics/diarization/sm_diarization_metrics/metrics/binary_classification.py new file mode 100644 index 0000000..66c4e28 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/binary_classification.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2016-2017 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +from collections import Counter + +import numpy as np +import sklearn.metrics +from sklearn.base import BaseEstimator +from sklearn.calibration import CalibratedClassifierCV +from sklearn.model_selection._split import _CVIterableWrapper + + +def det_curve(y_true, scores, distances=False): + """DET curve + + Parameters + ---------- + y_true : (n_samples, ) array-like + Boolean reference. + scores : (n_samples, ) array-like + Predicted score. + distances : boolean, optional + When True, indicate that `scores` are actually `distances` + + Returns + ------- + fpr : numpy array + False alarm rate + fnr : numpy array + False rejection rate + thresholds : numpy array + Corresponding thresholds + eer : float + Equal error rate + """ + + if distances: + scores = -scores + + # compute false positive and false negative rates + # (a.k.a. false alarm and false rejection rates) + fpr, tpr, thresholds = sklearn.metrics.roc_curve(y_true, scores, pos_label=True) + fnr = 1 - tpr + if distances: + thresholds = -thresholds + + # estimate equal error rate + eer_index = np.where(fpr > fnr)[0][0] + eer = 0.25 * (fpr[eer_index - 1] + fpr[eer_index] + fnr[eer_index - 1] + fnr[eer_index]) + + return fpr, fnr, thresholds, eer + + +def precision_recall_curve(y_true, scores, distances=False): + """Precision-recall curve + + Parameters + ---------- + y_true : (n_samples, ) array-like + Boolean reference. + scores : (n_samples, ) array-like + Predicted score. + distances : boolean, optional + When True, indicate that `scores` are actually `distances` + + Returns + ------- + precision : numpy array + Precision + recall : numpy array + Recall + thresholds : numpy array + Corresponding thresholds + auc : float + Area under curve + + """ + + if distances: + scores = -scores + + precision, recall, thresholds = sklearn.metrics.precision_recall_curve(y_true, scores, pos_label=True) + + if distances: + thresholds = -thresholds + + auc = sklearn.metrics.auc(precision, recall) + + return precision, recall, thresholds, auc + + +class _Passthrough(BaseEstimator): + """Dummy binary classifier used by score Calibration class""" + + def __init__(self): + super(_Passthrough, self).__init__() + self.classes_ = np.array([False, True], dtype=np.bool) + + def fit(self, scores, y_true): + return self + + def decision_function(self, scores): + """Returns the input scores unchanged""" + return scores + + +class Calibration(object): + """Probability calibration for binary classification tasks + + Parameters + ---------- + method : {'isotonic', 'sigmoid'}, optional + See `CalibratedClassifierCV`. Defaults to 'isotonic'. + equal_priors : bool, optional + Set to True to force equal priors. Default behavior is to estimate + priors from the data itself. + + Usage + ----- + >>> calibration = Calibration() + >>> calibration.fit(train_score, train_y) + >>> test_probability = calibration.transform(test_score) + + See also + -------- + CalibratedClassifierCV + + """ + + def __init__(self, equal_priors=False, method="isotonic"): + super(Calibration, self).__init__() + self.method = method + self.equal_priors = equal_priors + + def fit(self, scores, y_true): + """Train calibration + + Parameters + ---------- + scores : (n_samples, ) array-like + Uncalibrated scores. + y_true : (n_samples, ) array-like + True labels (dtype=bool). + """ + + # to force equal priors, randomly select (and average over) + # up to fifty balanced (i.e. #true == #false) calibration sets. + if self.equal_priors: + + counter = Counter(y_true) + positive, negative = counter[True], counter[False] + + if positive > negative: + majority, minority = True, False + n_majority, n_minority = positive, negative + else: + majority, minority = False, True + n_majority, n_minority = negative, positive + + n_splits = min(50, n_majority // n_minority + 1) + + minority_index = np.where(y_true == minority)[0] + majority_index = np.where(y_true == majority)[0] + + cv = [] + for _ in range(n_splits): + test_index = np.hstack( + [np.random.choice(majority_index, size=n_minority, replace=False), minority_index] + ) + cv.append(([], test_index)) + cv = _CVIterableWrapper(cv) + + # to estimate priors from the data itself, use the whole set + else: + cv = "prefit" + + self.calibration_ = CalibratedClassifierCV(base_estimator=_Passthrough(), method=self.method, cv=cv) + self.calibration_.fit(scores.reshape(-1, 1), y_true) + + return self + + def transform(self, scores): + """Calibrate scores into probabilities + + Parameters + ---------- + scores : (n_samples, ) array-like + Uncalibrated scores. + + Returns + ------- + probabilities : (n_samples, ) array-like + Calibrated scores (i.e. probabilities) + """ + return self.calibration_.predict_proba(scores.reshape(-1, 1))[:, 1] diff --git a/metrics/diarization/sm_diarization_metrics/metrics/detection.py b/metrics/diarization/sm_diarization_metrics/metrics/detection.py new file mode 100755 index 0000000..60a67c3 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/detection.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2020 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr +# Marvin LAVECHIN + +from .base import BaseMetric, f_measure +from .utils import UEMSupportMixin + +DER_NAME = "detection error rate" +DER_TOTAL = "total" +DER_FALSE_ALARM = "false alarm" +DER_MISS = "miss" + + +class DetectionErrorRate(UEMSupportMixin, BaseMetric): + """Detection error rate + + This metric can be used to evaluate binary classification tasks such as + speech activity detection, for instance. Inputs are expected to only + contain segments corresponding to the positive class (e.g. speech regions). + Gaps in the inputs considered as the negative class (e.g. non-speech + regions). + + It is computed as (fa + miss) / total, where fa is the duration of false + alarm (e.g. non-speech classified as speech), miss is the duration of + missed detection (e.g. speech classified as non-speech), and total is the + total duration of the positive class in the reference. + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments (one half before, one half after). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return DER_NAME + + @classmethod + def metric_components(cls): + return [DER_TOTAL, DER_FALSE_ALARM, DER_MISS] + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(DetectionErrorRate, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + + reference = reference.get_timeline(copy=False).support() + hypothesis = hypothesis.get_timeline(copy=False).support() + + reference_ = reference.gaps(support=uem) + hypothesis_ = hypothesis.gaps(support=uem) + + false_positive = 0.0 + for r_, h in reference_.co_iter(hypothesis): + false_positive += (r_ & h).duration + + false_negative = 0.0 + for r, h_ in reference.co_iter(hypothesis_): + false_negative += (r & h_).duration + + detail = {} + detail[DER_MISS] = false_negative + detail[DER_FALSE_ALARM] = false_positive + detail[DER_TOTAL] = reference.duration() + + return detail + + def compute_metric(self, detail): + error = 1.0 * (detail[DER_FALSE_ALARM] + detail[DER_MISS]) + total = 1.0 * detail[DER_TOTAL] + if total == 0.0: + if error == 0: + return 0.0 + else: + return 1.0 + else: + return error / total + + +ACCURACY_NAME = "detection accuracy" +ACCURACY_TRUE_POSITIVE = "true positive" +ACCURACY_TRUE_NEGATIVE = "true negative" +ACCURACY_FALSE_POSITIVE = "false positive" +ACCURACY_FALSE_NEGATIVE = "false negative" + + +class DetectionAccuracy(DetectionErrorRate): + """Detection accuracy + + This metric can be used to evaluate binary classification tasks such as + speech activity detection, for instance. Inputs are expected to only + contain segments corresponding to the positive class (e.g. speech regions). + Gaps in the inputs considered as the negative class (e.g. non-speech + regions). + + It is computed as (tp + tn) / total, where tp is the duration of true + positive (e.g. speech classified as speech), tn is the duration of true + negative (e.g. non-speech classified as non-speech), and total is the total + duration of the input signal. + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments (one half before, one half after). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return ACCURACY_NAME + + @classmethod + def metric_components(cls): + return [ACCURACY_TRUE_POSITIVE, ACCURACY_TRUE_NEGATIVE, ACCURACY_FALSE_POSITIVE, ACCURACY_FALSE_NEGATIVE] + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + + reference = reference.get_timeline(copy=False).support() + hypothesis = hypothesis.get_timeline(copy=False).support() + + reference_ = reference.gaps(support=uem) + hypothesis_ = hypothesis.gaps(support=uem) + + true_positive = 0.0 + for r, h in reference.co_iter(hypothesis): + true_positive += (r & h).duration + + true_negative = 0.0 + for r_, h_ in reference_.co_iter(hypothesis_): + true_negative += (r_ & h_).duration + + false_positive = 0.0 + for r_, h in reference_.co_iter(hypothesis): + false_positive += (r_ & h).duration + + false_negative = 0.0 + for r, h_ in reference.co_iter(hypothesis_): + false_negative += (r & h_).duration + + detail = {} + detail[ACCURACY_TRUE_NEGATIVE] = true_negative + detail[ACCURACY_TRUE_POSITIVE] = true_positive + detail[ACCURACY_FALSE_NEGATIVE] = false_negative + detail[ACCURACY_FALSE_POSITIVE] = false_positive + + return detail + + def compute_metric(self, detail): + numerator = 1.0 * (detail[ACCURACY_TRUE_NEGATIVE] + detail[ACCURACY_TRUE_POSITIVE]) + denominator = 1.0 * ( + detail[ACCURACY_TRUE_NEGATIVE] + + detail[ACCURACY_TRUE_POSITIVE] + + detail[ACCURACY_FALSE_NEGATIVE] + + detail[ACCURACY_FALSE_POSITIVE] + ) + + if denominator == 0.0: + return 1.0 + else: + return numerator / denominator + + +PRECISION_NAME = "detection precision" +PRECISION_RETRIEVED = "retrieved" +PRECISION_RELEVANT_RETRIEVED = "relevant retrieved" + + +class DetectionPrecision(DetectionErrorRate): + """Detection precision + + This metric can be used to evaluate binary classification tasks such as + speech activity detection, for instance. Inputs are expected to only + contain segments corresponding to the positive class (e.g. speech regions). + Gaps in the inputs considered as the negative class (e.g. non-speech + regions). + + It is computed as tp / (tp + fp), where tp is the duration of true positive + (e.g. speech classified as speech), and fp is the duration of false + positive (e.g. non-speech classified as speech). + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments (one half before, one half after). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return PRECISION_NAME + + @classmethod + def metric_components(cls): + return [PRECISION_RETRIEVED, PRECISION_RELEVANT_RETRIEVED] + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + + reference = reference.get_timeline(copy=False).support() + hypothesis = hypothesis.get_timeline(copy=False).support() + + reference_ = reference.gaps(support=uem) + + true_positive = 0.0 + for r, h in reference.co_iter(hypothesis): + true_positive += (r & h).duration + + false_positive = 0.0 + for r_, h in reference_.co_iter(hypothesis): + false_positive += (r_ & h).duration + + detail = {} + detail[PRECISION_RETRIEVED] = true_positive + false_positive + detail[PRECISION_RELEVANT_RETRIEVED] = true_positive + + return detail + + def compute_metric(self, detail): + relevant_retrieved = 1.0 * detail[PRECISION_RELEVANT_RETRIEVED] + retrieved = 1.0 * detail[PRECISION_RETRIEVED] + if retrieved == 0.0: + return 1.0 + else: + return relevant_retrieved / retrieved + + +RECALL_NAME = "detection recall" +RECALL_RELEVANT = "relevant" +RECALL_RELEVANT_RETRIEVED = "relevant retrieved" + + +class DetectionRecall(DetectionErrorRate): + """Detection recall + + This metric can be used to evaluate binary classification tasks such as + speech activity detection, for instance. Inputs are expected to only + contain segments corresponding to the positive class (e.g. speech regions). + Gaps in the inputs considered as the negative class (e.g. non-speech + regions). + + It is computed as tp / (tp + fn), where tp is the duration of true positive + (e.g. speech classified as speech), and fn is the duration of false + negative (e.g. speech classified as non-speech). + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments (one half before, one half after). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return RECALL_NAME + + @classmethod + def metric_components(cls): + return [RECALL_RELEVANT, RECALL_RELEVANT_RETRIEVED] + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + + reference = reference.get_timeline(copy=False).support() + hypothesis = hypothesis.get_timeline(copy=False).support() + + hypothesis_ = hypothesis.gaps(support=uem) + + true_positive = 0.0 + for r, h in reference.co_iter(hypothesis): + true_positive += (r & h).duration + + false_negative = 0.0 + for r, h_ in reference.co_iter(hypothesis_): + false_negative += (r & h_).duration + + detail = {} + detail[RECALL_RELEVANT] = true_positive + false_negative + detail[RECALL_RELEVANT_RETRIEVED] = true_positive + + return detail + + def compute_metric(self, detail): + relevant_retrieved = 1.0 * detail[RECALL_RELEVANT_RETRIEVED] + relevant = 1.0 * detail[RECALL_RELEVANT] + if relevant == 0.0: + if relevant_retrieved == 0: + return 1.0 + else: + return 0.0 + else: + return relevant_retrieved / relevant + + +DFS_NAME = "F[precision|recall]" +DFS_PRECISION_RETRIEVED = "retrieved" +DFS_RECALL_RELEVANT = "relevant" +DFS_RELEVANT_RETRIEVED = "relevant retrieved" + + +class DetectionPrecisionRecallFMeasure(UEMSupportMixin, BaseMetric): + """Compute detection precision and recall, and return their F-score + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments (one half before, one half after). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + beta : float, optional + When beta > 1, greater importance is given to recall. + When beta < 1, greater importance is given to precision. + Defaults to 1. + + See also + -------- + pyannote.metrics.detection.DetectionPrecision + pyannote.metrics.detection.DetectionRecall + pyannote.metrics.base.f_measure + + """ + + @classmethod + def metric_name(cls): + return DFS_NAME + + @classmethod + def metric_components(cls): + return [DFS_PRECISION_RETRIEVED, DFS_RECALL_RELEVANT, DFS_RELEVANT_RETRIEVED] + + def __init__(self, collar=0.0, skip_overlap=False, beta=1.0, **kwargs): + super(DetectionPrecisionRecallFMeasure, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + self.beta = beta + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + + reference = reference.get_timeline(copy=False).support() + hypothesis = hypothesis.get_timeline(copy=False).support() + + reference_ = reference.gaps(support=uem) + hypothesis_ = hypothesis.gaps(support=uem) + + # Better to recompute everything from scratch instead of calling the + # DetectionPrecision & DetectionRecall classes (we skip one of the loop + # that computes the amount of true positives). + true_positive = 0.0 + for r, h in reference.co_iter(hypothesis): + true_positive += (r & h).duration + + false_positive = 0.0 + for r_, h in reference_.co_iter(hypothesis): + false_positive += (r_ & h).duration + + false_negative = 0.0 + for r, h_ in reference.co_iter(hypothesis_): + false_negative += (r & h_).duration + + detail = { + DFS_PRECISION_RETRIEVED: true_positive + false_positive, + DFS_RECALL_RELEVANT: true_positive + false_negative, + DFS_RELEVANT_RETRIEVED: true_positive, + } + + return detail + + def compute_metric(self, detail): + _, _, value = self.compute_metrics(detail=detail) + return value + + def compute_metrics(self, detail=None): + + detail = self.accumulated_ if detail is None else detail + precision_retrieved = detail[DFS_PRECISION_RETRIEVED] + recall_relevant = detail[DFS_RECALL_RELEVANT] + relevant_retrieved = detail[DFS_RELEVANT_RETRIEVED] + + # Special cases : precision + if precision_retrieved == 0.0: + precision = 1 + else: + precision = relevant_retrieved / precision_retrieved + + # Special cases : recall + if recall_relevant == 0.0: + if relevant_retrieved == 0: + recall = 1.0 + else: + recall = 0.0 + else: + recall = relevant_retrieved / recall_relevant + + return precision, recall, f_measure(precision, recall, beta=self.beta) diff --git a/metrics/diarization/sm_diarization_metrics/metrics/diarization.py b/metrics/diarization/sm_diarization_metrics/metrics/diarization.py new file mode 100755 index 0000000..1096c89 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/diarization.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +"""Metrics for diarization""" + +import numpy as np + +from .base import BaseMetric, f_measure +from .identification import IdentificationErrorRate +from .matcher import GreedyMapper, HungarianMapper +from .utils import UEMSupportMixin + +DER_NAME = "diarization error rate" + + +class DiarizationErrorRate(IdentificationErrorRate): + """Diarization error rate + + First, the optimal mapping between reference and hypothesis labels + is obtained using the Hungarian algorithm. Then, the actual diarization + error rate is computed as the identification error rate with each hypothesis + label translated into the corresponding reference label. + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + Usage + ----- + + * Diarization error rate between `reference` and `hypothesis` annotations + + >>> metric = DiarizationErrorRate() + >>> reference = Annotation(...) # doctest: +SKIP + >>> hypothesis = Annotation(...) # doctest: +SKIP + >>> value = metric(reference, hypothesis) # doctest: +SKIP + + * Compute global diarization error rate and confidence interval + over multiple documents + + >>> for reference, hypothesis in ... # doctest: +SKIP + ... metric(reference, hypothesis) # doctest: +SKIP + >>> global_value = abs(metric) # doctest: +SKIP + >>> mean, (lower, upper) = metric.confidence_interval() # doctest: +SKIP + + * Get diarization error rate detailed components + + >>> components = metric(reference, hypothesis, detailed=True) #doctest +SKIP + + * Get accumulated components + + >>> components = metric[:] # doctest: +SKIP + >>> metric['confusion'] # doctest: +SKIP + + See Also + -------- + :class:`pyannote.metric.base.BaseMetric`: details on accumulation + :class:`pyannote.metric.identification.IdentificationErrorRate`: identification error rate + + """ + + @classmethod + def metric_name(cls): + return DER_NAME + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(DiarizationErrorRate, self).__init__(collar=collar, skip_overlap=skip_overlap, **kwargs) + self.mapper_ = HungarianMapper() + + def optimal_mapping(self, reference, hypothesis, uem=None): + """Optimal label mapping + + Parameters + ---------- + reference : Annotation + hypothesis : Annotation + Reference and hypothesis diarization + uem : Timeline + Evaluation map + + Returns + ------- + mapping : dict + Mapping between hypothesis (key) and reference (value) labels + """ + + # NOTE that this 'uemification' will not be called when + # 'optimal_mapping' is called from 'compute_components' as it + # has already been done in 'compute_components' + if uem: + reference, hypothesis = self.uemify(reference, hypothesis, uem=uem) + + # call hungarian mapper + return self.mapper_(hypothesis, reference) + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + # crop reference and hypothesis to evaluated regions (uem) + # remove collars around reference segment boundaries + # remove overlap regions (if requested) + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + # NOTE that this 'uemification' must be done here because it + # might have an impact on the search for the optimal mapping. + + # make sure reference only contains string labels ('A', 'B', ...) + reference = reference.rename_labels(generator="string") + + # make sure hypothesis only contains integer labels (1, 2, ...) + hypothesis = hypothesis.rename_labels(generator="int") + + # optimal (int --> str) mapping + mapping = self.optimal_mapping(reference, hypothesis) + + # compute identification error rate based on mapped hypothesis + # NOTE that collar is set to 0.0 because 'uemify' has already + # been applied (same reason for setting skip_overlap to False) + mapped = hypothesis.rename_labels(mapping=mapping) + return super(DiarizationErrorRate, self).compute_components( + reference, mapped, uem=uem, collar=0.0, skip_overlap=False, **kwargs + ) + + +class GreedyDiarizationErrorRate(IdentificationErrorRate): + """Greedy diarization error rate + + First, the greedy mapping between reference and hypothesis labels is + obtained. Then, the actual diarization error rate is computed as the + identification error rate with each hypothesis label translated into the + corresponding reference label. + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + Usage + ----- + + * Greedy diarization error rate between `reference` and `hypothesis` annotations + + >>> metric = GreedyDiarizationErrorRate() + >>> reference = Annotation(...) # doctest: +SKIP + >>> hypothesis = Annotation(...) # doctest: +SKIP + >>> value = metric(reference, hypothesis) # doctest: +SKIP + + * Compute global greedy diarization error rate and confidence interval + over multiple documents + + >>> for reference, hypothesis in ... # doctest: +SKIP + ... metric(reference, hypothesis) # doctest: +SKIP + >>> global_value = abs(metric) # doctest: +SKIP + >>> mean, (lower, upper) = metric.confidence_interval() # doctest: +SKIP + + * Get greedy diarization error rate detailed components + + >>> components = metric(reference, hypothesis, detailed=True) #doctest +SKIP + + * Get accumulated components + + >>> components = metric[:] # doctest: +SKIP + >>> metric['confusion'] # doctest: +SKIP + + See Also + -------- + :class:`pyannote.metric.base.BaseMetric`: details on accumulation + + """ + + @classmethod + def metric_name(cls): + return DER_NAME + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(GreedyDiarizationErrorRate, self).__init__(collar=collar, skip_overlap=skip_overlap, **kwargs) + self.mapper_ = GreedyMapper() + + def greedy_mapping(self, reference, hypothesis, uem=None): + """Greedy label mapping + + Parameters + ---------- + reference : Annotation + hypothesis : Annotation + Reference and hypothesis diarization + uem : Timeline + Evaluation map + + Returns + ------- + mapping : dict + Mapping between hypothesis (key) and reference (value) labels + """ + if uem: + reference, hypothesis = self.uemify(reference, hypothesis, uem=uem) + return self.mapper_(hypothesis, reference) + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + # crop reference and hypothesis to evaluated regions (uem) + # remove collars around reference segment boundaries + # remove overlap regions (if requested) + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + # NOTE that this 'uemification' must be done here because it + # might have an impact on the search for the greedy mapping. + + # make sure reference only contains string labels ('A', 'B', ...) + reference = reference.rename_labels(generator="string") + + # make sure hypothesis only contains integer labels (1, 2, ...) + hypothesis = hypothesis.rename_labels(generator="int") + + # greedy (int --> str) mapping + mapping = self.greedy_mapping(reference, hypothesis) + + # compute identification error rate based on mapped hypothesis + # NOTE that collar is set to 0.0 because 'uemify' has already + # been applied (same reason for setting skip_overlap to False) + mapped = hypothesis.rename_labels(mapping=mapping) + return super(GreedyDiarizationErrorRate, self).compute_components( + reference, mapped, uem=uem, collar=0.0, skip_overlap=False, **kwargs + ) + + +JER_NAME = "jaccard error rate" +JER_SPEAKER_ERROR = "speaker error" +JER_SPEAKER_COUNT = "speaker count" + + +class JaccardErrorRate(DiarizationErrorRate): + """Jaccard error rate + + Reference + --------- + Second DIHARD Challenge Evaluation Plan. Version 1.1 + N. Ryant, K. Church, C. Cieri, A. Cristia, J. Du, S. Ganapathy, M. Liberman + https://coml.lscp.ens.fr/dihard/2019/second_dihard_eval_plan_v1.1.pdf + + "The Jaccard error rate is based on the Jaccard index, a similarity measure + used to evaluate the output of image segmentation systems. An optimal + mapping between reference and system speakers is determined and for each + pair the Jaccard index is computed. The Jaccard error rate is then defined + as 1 minus the average of these scores. While similar to DER, it weights + every speaker’s contribution equally, regardless of how much speech they + actually produced. + + More concretely, assume we have N reference speakers and M system speakers. + An optimal mapping between speakers is determined using the Hungarian + algorithm so that each reference speaker is paired with at most one system + speaker and each system speaker with at most one reference speaker. Then, + for each reference speaker ref the speaker-specific Jaccard error rate + JERref is computed as JERref = (FA + MISS) / TOTAL where + * TOTAL is the duration of the union of reference and system speaker + segments; if the reference speaker was not paired with a system + speaker, it is the duration of all reference speaker segments + * FA is the total system speaker time not attributed to the reference + speaker; if the reference speaker was not paired with a system speaker, + it is 0 + * MISS is the total reference speaker time not attributed to the system + speaker; if the reference speaker was not paired with a system speaker, + it is equal to TOTAL + + The Jaccard error rate then is the average of the speaker specific Jaccard + error rates. + + JER and DER are highly correlated with JER typically being higher, + especially in recordings where one or more speakers is particularly + dominant. Where it tends to track DER is in outliers where the diarization + is especially bad, resulting in one or more unmapped system speakers whose + speech is not then penalized. In these cases, where DER can easily exceed + 500%, JER will never exceed 100% and may be far lower if the reference + speakers are handled correctly." + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + Usage + ----- + >>> metric = JaccardErrorRate() + >>> reference = Annotation(...) # doctest: +SKIP + >>> hypothesis = Annotation(...) # doctest: +SKIP + >>> jer = metric(reference, hypothesis) # doctest: +SKIP + + """ + + @classmethod + def metric_name(cls): + return JER_NAME + + @classmethod + def metric_components(cls): + return [ + JER_SPEAKER_COUNT, + JER_SPEAKER_ERROR, + ] + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super().__init__(collar=collar, skip_overlap=skip_overlap, **kwargs) + self.mapper_ = HungarianMapper() + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + # crop reference and hypothesis to evaluated regions (uem) + # remove collars around reference segment boundaries + # remove overlap regions (if requested) + reference, hypothesis, uem = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_uem=True + ) + # NOTE that this 'uemification' must be done here because it + # might have an impact on the search for the optimal mapping. + + # make sure reference only contains string labels ('A', 'B', ...) + reference = reference.rename_labels(generator="string") + + # make sure hypothesis only contains integer labels (1, 2, ...) + hypothesis = hypothesis.rename_labels(generator="int") + + # optimal (str --> int) mapping + mapping = self.optimal_mapping(hypothesis, reference) + + detail = self.init_components() + + for ref_speaker in reference.labels(): + + hyp_speaker = mapping.get(ref_speaker, None) + + if hyp_speaker is None: + # if the reference speaker was not paired with a system speaker + # [total] is the duration of all reference speaker segments + + # if the reference speaker was not paired with a system speaker + # [fa] is 0 + + # if the reference speaker was not paired with a system speaker + # [miss] is equal to total + + # overall: jer = (fa + miss) / total = (0 + total) / total = 1 + jer = 1.0 + + else: + # total is the duration of the union of reference and system + # speaker segments + r = reference.label_timeline(ref_speaker) + h = hypothesis.label_timeline(hyp_speaker) + total = r.union(h).support().duration() + + # fa is the total system speaker time not attributed to the + # reference speaker + fa = h.duration() - h.crop(r).duration() + + # miss is the total reference speaker time not attributed to + # the system speaker + miss = r.duration() - r.crop(h).duration() + + jer = (fa + miss) / total + + detail[JER_SPEAKER_COUNT] += 1 + detail[JER_SPEAKER_ERROR] += jer + + return detail + + def compute_metric(self, detail): + return detail[JER_SPEAKER_ERROR] / detail[JER_SPEAKER_COUNT] + + +PURITY_NAME = "purity" +PURITY_TOTAL = "total" +PURITY_CORRECT = "correct" + + +class DiarizationPurity(UEMSupportMixin, BaseMetric): + """Cluster purity + + A hypothesized annotation has perfect purity if all of its labels overlap + only segments which are members of a single reference label. + + Parameters + ---------- + weighted : bool, optional + When True (default), each cluster is weighted by its overall duration. + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return PURITY_NAME + + @classmethod + def metric_components(cls): + return [PURITY_TOTAL, PURITY_CORRECT] + + def __init__(self, collar=0.0, skip_overlap=False, weighted=True, **kwargs): + super(DiarizationPurity, self).__init__(**kwargs) + self.weighted = weighted + self.collar = collar + self.skip_overlap = skip_overlap + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + detail = self.init_components() + + # crop reference and hypothesis to evaluated regions (uem) + reference, hypothesis = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap + ) + + if not reference: + return detail + + # cooccurrence matrix + matrix = reference * hypothesis + + # duration of largest class in each cluster + largest = matrix.max(axis=0) + duration = matrix.sum(axis=0) + + if self.weighted: + detail[PURITY_CORRECT] = 0.0 + if np.prod(matrix.shape): + detail[PURITY_CORRECT] = largest.sum() + detail[PURITY_TOTAL] = duration.sum() + + else: + detail[PURITY_CORRECT] = (largest / duration).sum() + detail[PURITY_TOTAL] = len(largest) + + return detail + + def compute_metric(self, detail): + if detail[PURITY_TOTAL] > 0.0: + return detail[PURITY_CORRECT] / detail[PURITY_TOTAL] + return 1.0 + + +COVERAGE_NAME = "coverage" + + +class DiarizationCoverage(DiarizationPurity): + """Cluster coverage + + A hypothesized annotation has perfect coverage if all segments from a + given reference label are clustered in the same cluster. + + Parameters + ---------- + weighted : bool, optional + When True (default), each cluster is weighted by its overall duration. + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + @classmethod + def metric_name(cls): + return COVERAGE_NAME + + def __init__(self, collar=0.0, skip_overlap=False, weighted=True, **kwargs): + super(DiarizationCoverage, self).__init__(collar=collar, skip_overlap=skip_overlap, weighted=weighted, **kwargs) + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + return super(DiarizationCoverage, self).compute_components(hypothesis, reference, uem=uem, **kwargs) + + +PURITY_COVERAGE_NAME = "F[purity|coverage]" +PURITY_COVERAGE_LARGEST_CLASS = "largest_class" +PURITY_COVERAGE_TOTAL_CLUSTER = "total_cluster" +PURITY_COVERAGE_LARGEST_CLUSTER = "largest_cluster" +PURITY_COVERAGE_TOTAL_CLASS = "total_class" + + +class DiarizationPurityCoverageFMeasure(UEMSupportMixin, BaseMetric): + """Compute diarization purity and coverage, and return their F-score. + + Parameters + ---------- + weighted : bool, optional + When True (default), each cluster/class is weighted by its overall + duration. + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + beta : float, optional + When beta > 1, greater importance is given to coverage. + When beta < 1, greater importance is given to purity. + Defaults to 1. + + See also + -------- + pyannote.metrics.diarization.DiarizationPurity + pyannote.metrics.diarization.DiarizationCoverage + pyannote.metrics.base.f_measure + + """ + + @classmethod + def metric_name(cls): + return PURITY_COVERAGE_NAME + + @classmethod + def metric_components(cls): + return [ + PURITY_COVERAGE_LARGEST_CLASS, + PURITY_COVERAGE_TOTAL_CLUSTER, + PURITY_COVERAGE_LARGEST_CLUSTER, + PURITY_COVERAGE_TOTAL_CLASS, + ] + + def __init__(self, collar=0.0, skip_overlap=False, weighted=True, beta=1.0, **kwargs): + super(DiarizationPurityCoverageFMeasure, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + self.weighted = weighted + self.beta = beta + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + detail = self.init_components() + + # crop reference and hypothesis to evaluated regions (uem) + reference, hypothesis = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap + ) + + # cooccurrence matrix + matrix = reference * hypothesis + + # duration of largest class in each cluster + largest_class = matrix.max(axis=0) + # duration of clusters + duration_cluster = matrix.sum(axis=0) + + # duration of largest cluster in each class + largest_cluster = matrix.max(axis=1) + # duration of classes + duration_class = matrix.sum(axis=1) + + if self.weighted: + # compute purity components + detail[PURITY_COVERAGE_LARGEST_CLASS] = 0.0 + if np.prod(matrix.shape): + detail[PURITY_COVERAGE_LARGEST_CLASS] = largest_class.sum() + detail[PURITY_COVERAGE_TOTAL_CLUSTER] = duration_cluster.sum() + # compute coverage components + detail[PURITY_COVERAGE_LARGEST_CLUSTER] = 0.0 + if np.prod(matrix.shape): + detail[PURITY_COVERAGE_LARGEST_CLUSTER] = largest_cluster.sum() + detail[PURITY_COVERAGE_TOTAL_CLASS] = duration_class.sum() + + else: + # compute purity components + detail[PURITY_COVERAGE_LARGEST_CLASS] = (largest_class / duration_cluster).sum() + detail[PURITY_COVERAGE_TOTAL_CLUSTER] = len(largest_class) + # compute coverage components + detail[PURITY_COVERAGE_LARGEST_CLUSTER] = (largest_cluster / duration_class).sum() + detail[PURITY_COVERAGE_TOTAL_CLASS] = len(largest_cluster) + + # compute purity + detail[PURITY_NAME] = ( + 1.0 + if detail[PURITY_COVERAGE_TOTAL_CLUSTER] == 0.0 + else detail[PURITY_COVERAGE_LARGEST_CLASS] / detail[PURITY_COVERAGE_TOTAL_CLUSTER] + ) + # compute coverage + detail[COVERAGE_NAME] = ( + 1.0 + if detail[PURITY_COVERAGE_TOTAL_CLASS] == 0.0 + else detail[PURITY_COVERAGE_LARGEST_CLUSTER] / detail[PURITY_COVERAGE_TOTAL_CLASS] + ) + + return detail + + def compute_metric(self, detail): + _, _, value = self.compute_metrics(detail=detail) + return value + + def compute_metrics(self, detail=None): + + detail = self.accumulated_ if detail is None else detail + + purity = ( + 1.0 + if detail[PURITY_COVERAGE_TOTAL_CLUSTER] == 0.0 + else detail[PURITY_COVERAGE_LARGEST_CLASS] / detail[PURITY_COVERAGE_TOTAL_CLUSTER] + ) + + coverage = ( + 1.0 + if detail[PURITY_COVERAGE_TOTAL_CLASS] == 0.0 + else detail[PURITY_COVERAGE_LARGEST_CLUSTER] / detail[PURITY_COVERAGE_TOTAL_CLASS] + ) + + return purity, coverage, f_measure(purity, coverage, beta=self.beta) + + +HOMOGENEITY_NAME = "homogeneity" +HOMOGENEITY_ENTROPY = "entropy" +HOMOGENEITY_CROSS_ENTROPY = "cross-entropy" + + +class DiarizationHomogeneity(UEMSupportMixin, BaseMetric): + """Cluster homogeneity + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + """ + + @classmethod + def metric_name(cls): + return HOMOGENEITY_NAME + + @classmethod + def metric_components(cls): + return [HOMOGENEITY_ENTROPY, HOMOGENEITY_CROSS_ENTROPY] + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(DiarizationHomogeneity, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + detail = self.init_components() + + # crop reference and hypothesis to evaluated regions (uem) + reference, hypothesis = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap + ) + + # cooccurrence matrix + matrix = reference * hypothesis + + duration = np.sum(matrix) + rduration = np.sum(matrix, axis=1) + hduration = np.sum(matrix, axis=0) + + # reference entropy and reference/hypothesis cross-entropy + ratio = np.ma.divide(rduration, duration).filled(0.0) + detail[HOMOGENEITY_ENTROPY] = -np.sum(ratio * np.ma.log(ratio).filled(0.0)) + + ratio = np.ma.divide(matrix, duration).filled(0.0) + hratio = np.ma.divide(matrix, hduration).filled(0.0) + detail[HOMOGENEITY_CROSS_ENTROPY] = -np.sum(ratio * np.ma.log(hratio).filled(0.0)) + + return detail + + def compute_metric(self, detail): + numerator = 1.0 * detail[HOMOGENEITY_CROSS_ENTROPY] + denominator = 1.0 * detail[HOMOGENEITY_ENTROPY] + if denominator == 0.0: + if numerator == 0: + return 1.0 + else: + return 0.0 + else: + return 1.0 - numerator / denominator + + +COMPLETENESS_NAME = "completeness" + + +class DiarizationCompleteness(DiarizationHomogeneity): + """Cluster completeness + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + """ + + @classmethod + def metric_name(cls): + return COMPLETENESS_NAME + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + return super(DiarizationCompleteness, self).compute_components(hypothesis, reference, uem=uem, **kwargs) diff --git a/metrics/diarization/sm_diarization_metrics/metrics/errors/__init__.py b/metrics/diarization/sm_diarization_metrics/metrics/errors/__init__.py new file mode 100644 index 0000000..df73286 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/errors/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +"""Error analysis""" diff --git a/metrics/diarization/sm_diarization_metrics/metrics/errors/identification.py b/metrics/diarization/sm_diarization_metrics/metrics/errors/identification.py new file mode 100755 index 0000000..606c267 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/errors/identification.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr +# Benjamin MAURICE - maurice@limsi.fr + +import numpy as np +from pyannote.core import Annotation +from scipy.optimize import linear_sum_assignment + +from ..identification import UEMSupportMixin +from ..matcher import MATCH_CONFUSION, MATCH_CORRECT, MATCH_FALSE_ALARM, MATCH_MISSED_DETECTION, LabelMatcher + +REFERENCE_TOTAL = "reference" +HYPOTHESIS_TOTAL = "hypothesis" + +REGRESSION = "regression" +IMPROVEMENT = "improvement" +BOTH_CORRECT = "both_correct" +BOTH_INCORRECT = "both_incorrect" + + +class IdentificationErrorAnalysis(UEMSupportMixin, object): + """ + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + def __init__(self, collar=0.0, skip_overlap=False): + + super(IdentificationErrorAnalysis, self).__init__() + self.matcher = LabelMatcher() + self.collar = collar + self.skip_overlap = skip_overlap + + def difference(self, reference, hypothesis, uem=None, uemified=False): + """Get error analysis as `Annotation` + + Labels are (status, reference_label, hypothesis_label) tuples. + `status` is either 'correct', 'confusion', 'missed detection' or + 'false alarm'. + `reference_label` is None in case of 'false alarm'. + `hypothesis_label` is None in case of 'missed detection'. + + Parameters + ---------- + uemified : bool, optional + Returns "uemified" version of reference and hypothesis. + Defaults to False. + + Returns + ------- + errors : `Annotation` + + """ + + R, H, common_timeline = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_timeline=True + ) + + errors = Annotation(uri=reference.uri, modality=reference.modality) + + # loop on all segments + for segment in common_timeline: + + # list of labels in reference segment + rlabels = R.get_labels(segment, unique=False) + + # list of labels in hypothesis segment + hlabels = H.get_labels(segment, unique=False) + + _, details = self.matcher(rlabels, hlabels) + + for r, h in details[MATCH_CORRECT]: + track = errors.new_track(segment, prefix=MATCH_CORRECT) + errors[segment, track] = (MATCH_CORRECT, r, h) + + for r, h in details[MATCH_CONFUSION]: + track = errors.new_track(segment, prefix=MATCH_CONFUSION) + errors[segment, track] = (MATCH_CONFUSION, r, h) + + for r in details[MATCH_MISSED_DETECTION]: + track = errors.new_track(segment, prefix=MATCH_MISSED_DETECTION) + errors[segment, track] = (MATCH_MISSED_DETECTION, r, None) + + for h in details[MATCH_FALSE_ALARM]: + track = errors.new_track(segment, prefix=MATCH_FALSE_ALARM) + errors[segment, track] = (MATCH_FALSE_ALARM, None, h) + + if uemified: + return reference, hypothesis, errors + else: + return errors + + def _match_errors(self, before, after): + b_type, b_ref, b_hyp = before + a_type, a_ref, a_hyp = after + return (b_ref == a_ref) * (1 + (b_type == a_type) + (b_hyp == a_hyp)) + + def regression(self, reference, before, after, uem=None, uemified=False): + + _, before, errors_before = self.difference(reference, before, uem=uem, uemified=True) + + reference, after, errors_after = self.difference(reference, after, uem=uem, uemified=True) + + behaviors = Annotation(uri=reference.uri, modality=reference.modality) + + # common (up-sampled) timeline + common_timeline = errors_after.get_timeline().union(errors_before.get_timeline()) + common_timeline = common_timeline.segmentation() + + # align 'before' errors on common timeline + B = self._tagger(errors_before, common_timeline) + + # align 'after' errors on common timeline + A = self._tagger(errors_after, common_timeline) + + for segment in common_timeline: + + old_errors = B.get_labels(segment, unique=False) + new_errors = A.get_labels(segment, unique=False) + + n1 = len(old_errors) + n2 = len(new_errors) + n = max(n1, n2) + + match = np.zeros((n, n), dtype=int) + for i1, e1 in enumerate(old_errors): + for i2, e2 in enumerate(new_errors): + match[i1, i2] = self._match_errors(e1, e2) + + for i1, i2 in zip(*linear_sum_assignment(-match)): + + if i1 >= n1: + track = behaviors.new_track(segment, candidate=REGRESSION, prefix=REGRESSION) + behaviors[segment, track] = (REGRESSION, None, new_errors[i2]) + + elif i2 >= n2: + track = behaviors.new_track(segment, candidate=IMPROVEMENT, prefix=IMPROVEMENT) + behaviors[segment, track] = (IMPROVEMENT, old_errors[i1], None) + + elif old_errors[i1][0] == MATCH_CORRECT: + + if new_errors[i2][0] == MATCH_CORRECT: + track = behaviors.new_track(segment, candidate=BOTH_CORRECT, prefix=BOTH_CORRECT) + behaviors[segment, track] = (BOTH_CORRECT, old_errors[i1], new_errors[i2]) + + else: + track = behaviors.new_track(segment, candidate=REGRESSION, prefix=REGRESSION) + behaviors[segment, track] = (REGRESSION, old_errors[i1], new_errors[i2]) + + else: + + if new_errors[i2][0] == MATCH_CORRECT: + track = behaviors.new_track(segment, candidate=IMPROVEMENT, prefix=IMPROVEMENT) + behaviors[segment, track] = (IMPROVEMENT, old_errors[i1], new_errors[i2]) + + else: + track = behaviors.new_track(segment, candidate=BOTH_INCORRECT, prefix=BOTH_INCORRECT) + behaviors[segment, track] = (BOTH_INCORRECT, old_errors[i1], new_errors[i2]) + + behaviors = behaviors.support() + + if uemified: + return reference, before, after, behaviors + else: + return behaviors + + def matrix(self, reference, hypothesis, uem=None): + + reference, hypothesis, errors = self.difference(reference, hypothesis, uem=uem, uemified=True) + + chart = errors.chart() + + # rLabels contains reference labels + # hLabels contains hypothesis labels confused with a reference label + # falseAlarmLabels contains false alarm hypothesis labels that do not + # exist in reference labels // corner case // + + falseAlarmLabels = set(hypothesis.labels()) - set(reference.labels()) + hLabels = set(reference.labels()) | set(hypothesis.labels()) + rLabels = set(reference.labels()) + + # sort these sets of labels + cmp_func = reference._cmp_labels + falseAlarmLabels = sorted(falseAlarmLabels, cmp=cmp_func) + rLabels = sorted(rLabels, cmp=cmp_func) + hLabels = sorted(hLabels, cmp=cmp_func) + + # append false alarm labels as last 'reference' labels + # (make sure to mark them as such) + rLabels = rLabels + [(MATCH_FALSE_ALARM, hLabel) for hLabel in falseAlarmLabels] + + # prepend duration columns before the detailed confusion matrix + hLabels = [ + REFERENCE_TOTAL, + HYPOTHESIS_TOTAL, + MATCH_CORRECT, + MATCH_CONFUSION, + MATCH_FALSE_ALARM, + MATCH_MISSED_DETECTION, + ] + hLabels + + # initialize empty matrix + + try: + from xarray import DataArray + except ImportError: + msg = "Please install xarray dependency to use class " "'IdentificationErrorAnalysis'." + raise ImportError(msg) + + matrix = DataArray( + np.zeros((len(rLabels), len(hLabels))), coords=[("reference", rLabels), ("hypothesis", hLabels)] + ) + + # loop on chart + for (status, rLabel, hLabel), duration in chart: + + # increment correct + if status == MATCH_CORRECT: + matrix.loc[rLabel, hLabel] += duration + matrix.loc[rLabel, MATCH_CORRECT] += duration + + # increment confusion matrix + if status == MATCH_CONFUSION: + matrix.loc[rLabel, hLabel] += duration + matrix.loc[rLabel, MATCH_CONFUSION] += duration + if hLabel in falseAlarmLabels: + matrix.loc[(MATCH_FALSE_ALARM, hLabel), rLabel] += duration + matrix.loc[(MATCH_FALSE_ALARM, hLabel), MATCH_CONFUSION] += duration + else: + matrix.loc[hLabel, rLabel] += duration + matrix.loc[hLabel, MATCH_CONFUSION] += duration + + if status == MATCH_FALSE_ALARM: + # hLabel is also a reference label + if hLabel in falseAlarmLabels: + matrix.loc[(MATCH_FALSE_ALARM, hLabel), MATCH_FALSE_ALARM] += duration + else: + matrix.loc[hLabel, MATCH_FALSE_ALARM] += duration + + if status == MATCH_MISSED_DETECTION: + matrix.loc[rLabel, MATCH_MISSED_DETECTION] += duration + + # total reference and hypothesis duration + for rLabel in rLabels: + + if isinstance(rLabel, tuple) and rLabel[0] == MATCH_FALSE_ALARM: + r = 0.0 + h = hypothesis.label_duration(rLabel[1]) + else: + r = reference.label_duration(rLabel) + h = hypothesis.label_duration(rLabel) + + matrix.loc[rLabel, REFERENCE_TOTAL] = r + matrix.loc[rLabel, HYPOTHESIS_TOTAL] = h + + return matrix diff --git a/metrics/diarization/sm_diarization_metrics/metrics/errors/segmentation.py b/metrics/diarization/sm_diarization_metrics/metrics/errors/segmentation.py new file mode 100644 index 0000000..064b15d --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/errors/segmentation.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2017 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + + +from pyannote.core import Annotation, Timeline + + +class SegmentationErrorAnalysis(object): + def __init__(self): + super(SegmentationErrorAnalysis, self).__init__() + + def __call__(self, reference, hypothesis): + + if isinstance(reference, Annotation): + reference = reference.get_timeline() + + if isinstance(hypothesis, Annotation): + hypothesis = hypothesis.get_timeline() + + # over-segmentation + over = Timeline(uri=reference.uri) + prev_r = reference[0] + intersection = [] + for r, h in reference.co_iter(hypothesis): + + if r != prev_r: + intersection = sorted(intersection) + for _, segment in intersection[:-1]: + over.add(segment) + intersection = [] + prev_r = r + + segment = r & h + intersection.append((segment.duration, segment)) + + intersection = sorted(intersection) + for _, segment in intersection[:-1]: + over.add(segment) + + # under-segmentation + under = Timeline(uri=reference.uri) + prev_h = hypothesis[0] + intersection = [] + for h, r in hypothesis.co_iter(reference): + + if h != prev_h: + intersection = sorted(intersection) + for _, segment in intersection[:-1]: + under.add(segment) + intersection = [] + prev_h = h + + segment = h & r + intersection.append((segment.duration, segment)) + + intersection = sorted(intersection) + for _, segment in intersection[:-1]: + under.add(segment) + + # extent + extent = reference.extent() + + # frontier error (both under- and over-segmented) + frontier = under.crop(over) + + # under-segmented + not_over = over.gaps(support=extent) + only_under = under.crop(not_over) + + # over-segmented + not_under = under.gaps(support=extent) + only_over = over.crop(not_under) + + status = Annotation(uri=reference.uri) + for segment in frontier: + status[segment, "_"] = "shift" + for segment in only_over: + status[segment, "_"] = "over-segmentation" + for segment in only_under: + status[segment, "_"] = "under-segmentation" + + return status.support() diff --git a/metrics/diarization/sm_diarization_metrics/metrics/identification.py b/metrics/diarization/sm_diarization_metrics/metrics/identification.py new file mode 100755 index 0000000..405ee0c --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/identification.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +from .base import ( + PRECISION_RELEVANT_RETRIEVED, + PRECISION_RETRIEVED, + RECALL_RELEVANT, + RECALL_RELEVANT_RETRIEVED, + BaseMetric, + Precision, + Recall, +) +from .matcher import ( + MATCH_CONFUSION, + MATCH_CORRECT, + MATCH_FALSE_ALARM, + MATCH_MISSED_DETECTION, + MATCH_TOTAL, + LabelMatcher, +) +from .utils import UEMSupportMixin + +IER_TOTAL = MATCH_TOTAL +IER_CORRECT = MATCH_CORRECT +IER_CONFUSION = MATCH_CONFUSION +IER_FALSE_ALARM = MATCH_FALSE_ALARM +IER_MISS = MATCH_MISSED_DETECTION +IER_NAME = "identification error rate" + + +class IdentificationErrorRate(UEMSupportMixin, BaseMetric): + """Identification error rate + + ``ier = (wc x confusion + wf x false_alarm + wm x miss) / total`` + + where + - `confusion` is the total confusion duration in seconds + - `false_alarm` is the total hypothesis duration where there are + - `miss` is + - `total` is the total duration of all tracks + - wc, wf and wm are optional weights (default to 1) + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + confusion, miss, false_alarm: float, optional + Optional weights for confusion, miss and false alarm respectively. + Default to 1. (no weight) + """ + + @classmethod + def metric_name(cls): + return IER_NAME + + @classmethod + def metric_components(cls): + return [IER_TOTAL, IER_CORRECT, IER_FALSE_ALARM, IER_MISS, IER_CONFUSION] + + def __init__(self, confusion=1.0, miss=1.0, false_alarm=1.0, collar=0.0, skip_overlap=False, **kwargs): + + super(IdentificationErrorRate, self).__init__(**kwargs) + self.matcher_ = LabelMatcher() + self.confusion = confusion + self.miss = miss + self.false_alarm = false_alarm + self.collar = collar + self.skip_overlap = skip_overlap + + def compute_components(self, reference, hypothesis, uem=None, collar=None, skip_overlap=None, **kwargs): + """ + + Parameters + ---------- + collar : float, optional + Override self.collar + skip_overlap : bool, optional + Override self.skip_overlap + + See also + -------- + :class:`pyannote.metric.diarization.DiarizationErrorRate` uses these + two options in its `compute_components` method. + + """ + + detail = self.init_components() + + if collar is None: + collar = self.collar + if skip_overlap is None: + skip_overlap = self.skip_overlap + + R, H, common_timeline = self.uemify( + reference, hypothesis, uem=uem, collar=collar, skip_overlap=skip_overlap, returns_timeline=True + ) + + # loop on all segments + for segment in common_timeline: + + # segment duration + duration = segment.duration + + # list of IDs in reference segment + r = R.get_labels(segment, unique=False) + + # list of IDs in hypothesis segment + h = H.get_labels(segment, unique=False) + + counts, _ = self.matcher_(r, h) + + detail[IER_TOTAL] += duration * counts[IER_TOTAL] + detail[IER_CORRECT] += duration * counts[IER_CORRECT] + detail[IER_CONFUSION] += duration * counts[IER_CONFUSION] + detail[IER_MISS] += duration * counts[IER_MISS] + detail[IER_FALSE_ALARM] += duration * counts[IER_FALSE_ALARM] + + return detail + + def compute_metric(self, detail): + + numerator = 1.0 * ( + self.confusion * detail[IER_CONFUSION] + + self.false_alarm * detail[IER_FALSE_ALARM] + + self.miss * detail[IER_MISS] + ) + denominator = 1.0 * detail[IER_TOTAL] + if denominator == 0.0: + if numerator == 0: + return 0.0 + else: + return 1.0 + else: + return numerator / denominator + + +class IdentificationPrecision(UEMSupportMixin, Precision): + """Identification Precision + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(IdentificationPrecision, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + self.matcher_ = LabelMatcher() + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + detail = self.init_components() + + R, H, common_timeline = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_timeline=True + ) + + # loop on all segments + for segment in common_timeline: + + # segment duration + duration = segment.duration + + # list of IDs in reference segment + r = R.get_labels(segment, unique=False) + + # list of IDs in hypothesis segment + h = H.get_labels(segment, unique=False) + + counts, _ = self.matcher_(r, h) + + detail[PRECISION_RETRIEVED] += duration * len(h) + detail[PRECISION_RELEVANT_RETRIEVED] += duration * counts[IER_CORRECT] + + return detail + + +class IdentificationRecall(UEMSupportMixin, Recall): + """Identification Recall + + Parameters + ---------- + collar : float, optional + Duration (in seconds) of collars removed from evaluation around + boundaries of reference segments. + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + """ + + def __init__(self, collar=0.0, skip_overlap=False, **kwargs): + super(IdentificationRecall, self).__init__(**kwargs) + self.collar = collar + self.skip_overlap = skip_overlap + self.matcher_ = LabelMatcher() + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + + detail = self.init_components() + + R, H, common_timeline = self.uemify( + reference, hypothesis, uem=uem, collar=self.collar, skip_overlap=self.skip_overlap, returns_timeline=True + ) + + # loop on all segments + for segment in common_timeline: + + # segment duration + duration = segment.duration + + # list of IDs in reference segment + r = R.get_labels(segment, unique=False) + + # list of IDs in hypothesis segment + h = H.get_labels(segment, unique=False) + + counts, _ = self.matcher_(r, h) + + detail[RECALL_RELEVANT] += duration * counts[IER_TOTAL] + detail[RECALL_RELEVANT_RETRIEVED] += duration * counts[IER_CORRECT] + + return detail diff --git a/metrics/diarization/sm_diarization_metrics/metrics/matcher.py b/metrics/diarization/sm_diarization_metrics/metrics/matcher.py new file mode 100644 index 0000000..028e361 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/matcher.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +import numpy as np +from scipy.optimize import linear_sum_assignment + +MATCH_CORRECT = "correct" +MATCH_CONFUSION = "confusion" +MATCH_MISSED_DETECTION = "missed detection" +MATCH_FALSE_ALARM = "false alarm" +MATCH_TOTAL = "total" + + +class LabelMatcher(object): + """ + ID matcher base class. + + All ID matcher classes must inherit from this class and implement + .match() -- ie return True if two IDs match and False + otherwise. + """ + + def match(self, rlabel, hlabel): + """ + Parameters + ---------- + rlabel : + Reference label + hlabel : + Hypothesis label + + Returns + ------- + match : bool + True if labels match, False otherwise. + + """ + # Two IDs match if they are equal to each other + return rlabel == hlabel + + def __call__(self, rlabels, hlabels): + """ + + Parameters + ---------- + rlabels, hlabels : iterable + Reference and hypothesis labels + + Returns + ------- + counts : dict + details : dict + + """ + + # counts and details + counts = {MATCH_CORRECT: 0, MATCH_CONFUSION: 0, MATCH_MISSED_DETECTION: 0, MATCH_FALSE_ALARM: 0, MATCH_TOTAL: 0} + + details = {MATCH_CORRECT: [], MATCH_CONFUSION: [], MATCH_MISSED_DETECTION: [], MATCH_FALSE_ALARM: []} + + NR = len(rlabels) + NH = len(hlabels) + N = max(NR, NH) + + # corner case + if N == 0: + return (counts, details) + + # this is to make sure rlabels and hlabels are lists + # as we will access them later by index + rlabels = list(rlabels) + hlabels = list(hlabels) + + # initialize match matrix + # with True if labels match and False otherwise + match = np.zeros((N, N), dtype=bool) + for r, rlabel in enumerate(rlabels): + for h, hlabel in enumerate(hlabels): + match[r, h] = self.match(rlabel, hlabel) + + # find one-to-one mapping that maximize total number of matches + # using the Hungarian algorithm and computes error accordingly + for r, h in zip(*linear_sum_assignment(~match)): + + # hypothesis label is matched with unexisting reference label + # ==> this is a false alarm + if r >= NR: + counts[MATCH_FALSE_ALARM] += 1 + details[MATCH_FALSE_ALARM].append(hlabels[h]) + + # reference label is matched with unexisting hypothesis label + # ==> this is a missed detection + elif h >= NH: + counts[MATCH_MISSED_DETECTION] += 1 + details[MATCH_MISSED_DETECTION].append(rlabels[r]) + + # reference and hypothesis labels match + # ==> this is a correct detection + elif match[r, h]: + counts[MATCH_CORRECT] += 1 + details[MATCH_CORRECT].append((rlabels[r], hlabels[h])) + + # refernece and hypothesis do not match + # ==> this is a confusion + else: + counts[MATCH_CONFUSION] += 1 + details[MATCH_CONFUSION].append((rlabels[r], hlabels[h])) + + counts[MATCH_TOTAL] += NR + + # returns counts and details + return (counts, details) + + +class HungarianMapper(object): + def __call__(self, A, B): + mapping = {} + + cooccurrence = A * B + a_labels, b_labels = A.labels(), B.labels() + + for a, b in zip(*linear_sum_assignment(-cooccurrence)): + if cooccurrence[a, b] > 0: + mapping[a_labels[a]] = b_labels[b] + + return mapping + + +class GreedyMapper(object): + def __call__(self, A, B): + mapping = {} + + cooccurrence = A * B + Na, Nb = cooccurrence.shape + a_labels, b_labels = A.labels(), B.labels() + + for i in range(min(Na, Nb)): + a, b = np.unravel_index(np.argmax(cooccurrence), (Na, Nb)) + + if cooccurrence[a, b] > 0: + mapping[a_labels[a]] = b_labels[b] + cooccurrence[a, :] = 0.0 + cooccurrence[:, b] = 0.0 + continue + + break + + return mapping diff --git a/metrics/diarization/sm_diarization_metrics/metrics/plot/__init__.py b/metrics/diarization/sm_diarization_metrics/metrics/plot/__init__.py new file mode 100644 index 0000000..3e94033 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/plot/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2016 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr diff --git a/metrics/diarization/sm_diarization_metrics/metrics/plot/binary_classification.py b/metrics/diarization/sm_diarization_metrics/metrics/plot/binary_classification.py new file mode 100644 index 0000000..ce3b14c --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/plot/binary_classification.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2016 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + + +import warnings + +import matplotlib +import numpy as np +from pyannote.metrics.binary_classification import det_curve, precision_recall_curve + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +def plot_distributions(y_true, scores, save_to, xlim=None, nbins=100, ymax=3.0, dpi=150): + """Scores distributions + + This function will create (and overwrite) the following files: + - {save_to}.scores.png + - {save_to}.scores.eps + + Parameters + ---------- + y_true : (n_samples, ) array-like + Boolean reference. + scores : (n_samples, ) array-like + Predicted score. + save_to : str + Files path prefix + """ + + plt.figure(figsize=(12, 12)) + + if xlim is None: + xlim = (np.min(scores), np.max(scores)) + + bins = np.linspace(xlim[0], xlim[1], nbins) + plt.hist(scores[y_true], bins=bins, color="g", alpha=0.5, normed=True) + plt.hist(scores[~y_true], bins=bins, color="r", alpha=0.5, normed=True) + + # TODO heuristic to estimate ymax from nbins and xlim + plt.ylim(0, ymax) + plt.tight_layout() + plt.savefig(save_to + ".scores.png", dpi=dpi) + plt.savefig(save_to + ".scores.eps") + plt.close() + + return True + + +def plot_det_curve(y_true, scores, save_to, distances=False, dpi=150): + """DET curve + + This function will create (and overwrite) the following files: + - {save_to}.det.png + - {save_to}.det.eps + - {save_to}.det.txt + + Parameters + ---------- + y_true : (n_samples, ) array-like + Boolean reference. + scores : (n_samples, ) array-like + Predicted score. + save_to : str + Files path prefix. + distances : boolean, optional + When True, indicate that `scores` are actually `distances` + dpi : int, optional + Resolution of .png file. Defaults to 150. + + Returns + ------- + eer : float + Equal error rate + """ + + fpr, fnr, thresholds, eer = det_curve(y_true, scores, distances=distances) + + # plot DET curve + plt.figure(figsize=(12, 12)) + plt.loglog(fpr, fnr, "b") + plt.loglog([eer], [eer], "bo") + plt.xlabel("False Positive Rate") + plt.ylabel("False Negative Rate") + plt.xlim(1e-2, 1.0) + plt.ylim(1e-2, 1.0) + plt.grid(True) + plt.tight_layout() + plt.savefig(save_to + ".det.png", dpi=dpi) + plt.savefig(save_to + ".det.eps") + plt.close() + + # save DET curve in text file + txt = save_to + ".det.txt" + line = "{t:.6f} {fp:.6f} {fn:.6f}\n" + with open(txt, "w") as f: + for i, (t, fp, fn) in enumerate(zip(thresholds, fpr, fnr)): + f.write(line.format(t=t, fp=fp, fn=fn)) + + return eer + + +def plot_precision_recall_curve(y_true, scores, save_to, distances=False, dpi=150): + """Precision/recall curve + + This function will create (and overwrite) the following files: + - {save_to}.precision_recall.png + - {save_to}.precision_recall.eps + - {save_to}.precision_recall.txt + + Parameters + ---------- + y_true : (n_samples, ) array-like + Boolean reference. + scores : (n_samples, ) array-like + Predicted score. + save_to : str + Files path prefix. + distances : boolean, optional + When True, indicate that `scores` are actually `distances` + dpi : int, optional + Resolution of .png file. Defaults to 150. + + Returns + ------- + auc : float + Area under precision/recall curve + """ + + precision, recall, thresholds, auc = precision_recall_curve(y_true, scores, distances=distances) + + # plot P/R curve + plt.figure(figsize=(12, 12)) + plt.plot(recall, precision, "b") + plt.xlabel("Recall") + plt.ylabel("Precision") + plt.xlim(0, 1) + plt.ylim(0, 1) + plt.tight_layout() + plt.savefig(save_to + ".precision_recall.png", dpi=dpi) + plt.savefig(save_to + ".precision_recall.eps") + plt.close() + + # save P/R curve in text file + txt = save_to + ".precision_recall.txt" + line = "{t:.6f} {p:.6f} {r:.6f}\n" + with open(txt, "w") as f: + for i, (t, p, r) in enumerate(zip(thresholds, precision, recall)): + f.write(line.format(t=t, p=p, r=r)) + + return auc diff --git a/metrics/diarization/sm_diarization_metrics/metrics/segmentation.py b/metrics/diarization/sm_diarization_metrics/metrics/segmentation.py new file mode 100755 index 0000000..40b786a --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/segmentation.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr +# Camille Guinaudeau - https://sites.google.com/site/cguinaudeau/ +# Mamadou Doumbia +# Diego Fustes diego.fustes at toptal.com + +import numpy as np +from pyannote.core import Annotation, Segment, Timeline +from pyannote.core.utils.generators import pairwise + +from .base import BaseMetric, f_measure +from .utils import UEMSupportMixin + +PURITY_NAME = "segmentation purity" +COVERAGE_NAME = "segmentation coverage" +PURITY_COVERAGE_NAME = "segmentation F[purity|coverage]" +PTY_CVG_TOTAL = "total duration" +PTY_CVG_INTER = "intersection duration" + +PTY_TOTAL = "pty total duration" +PTY_INTER = "pty intersection duration" +CVG_TOTAL = "cvg total duration" +CVG_INTER = "cvg intersection duration" + +PRECISION_NAME = "segmentation precision" +RECALL_NAME = "segmentation recall" + +PR_BOUNDARIES = "number of boundaries" +PR_MATCHES = "number of matches" + + +class SegmentationCoverage(BaseMetric): + """Segmentation coverage + + Parameters + ---------- + tolerance : float, optional + When provided, preprocess reference by filling intra-label gaps shorter + than `tolerance` (in seconds). + + """ + + def __init__(self, tolerance=0.500, **kwargs): + super(SegmentationCoverage, self).__init__(**kwargs) + self.tolerance = tolerance + + def _partition(self, timeline, coverage): + + # boundaries (as set of timestamps) + boundaries = set([]) + for segment in timeline: + boundaries.add(segment.start) + boundaries.add(segment.end) + + # partition (as timeline) + partition = Annotation() + for start, end in pairwise(sorted(boundaries)): + segment = Segment(start, end) + partition[segment] = "_" + + return partition.crop(coverage, mode="intersection").relabel_tracks() + + def _preprocess(self, reference, hypothesis): + + if not isinstance(reference, Annotation): + raise TypeError("reference must be an instance of `Annotation`") + + if isinstance(hypothesis, Annotation): + hypothesis = hypothesis.get_timeline() + + # reference where short intra-label gaps are removed + filled = Timeline() + for label in reference.labels(): + label_timeline = reference.label_timeline(label) + for gap in label_timeline.gaps(): + if gap.duration < self.tolerance: + label_timeline.add(gap) + + for segment in label_timeline.support(): + filled.add(segment) + + # reference coverage after filling gaps + coverage = filled.support() + + reference_partition = self._partition(filled, coverage) + hypothesis_partition = self._partition(hypothesis, coverage) + + return reference_partition, hypothesis_partition + + def _process(self, reference, hypothesis): + + detail = self.init_components() + + # cooccurrence matrix + K = reference * hypothesis + detail[PTY_CVG_TOTAL] = np.sum(K).item() + detail[PTY_CVG_INTER] = np.sum(np.max(K, axis=1)).item() + + return detail + + @classmethod + def metric_name(cls): + return COVERAGE_NAME + + @classmethod + def metric_components(cls): + return [PTY_CVG_TOTAL, PTY_CVG_INTER] + + def compute_components(self, reference, hypothesis, **kwargs): + reference, hypothesis = self._preprocess(reference, hypothesis) + return self._process(reference, hypothesis) + + def compute_metric(self, detail): + return detail[PTY_CVG_INTER] / detail[PTY_CVG_TOTAL] + + +class SegmentationPurity(SegmentationCoverage): + """Segmentation purity + + Parameters + ---------- + tolerance : float, optional + When provided, preprocess reference by filling intra-label gaps shorter + than `tolerance` (in seconds). + + """ + + @classmethod + def metric_name(cls): + return PURITY_NAME + + def compute_components(self, reference, hypothesis, **kwargs): + reference, hypothesis = self._preprocess(reference, hypothesis) + return self._process(hypothesis, reference) + + +class SegmentationPurityCoverageFMeasure(SegmentationCoverage): + """ + Compute segmentation purity and coverage, and return their F-score. + + + Parameters + ---------- + tolerance : float, optional + When provided, preprocess reference by filling intra-label gaps shorter + than `tolerance` (in seconds). + + beta : float, optional + When beta > 1, greater importance is given to coverage. + When beta < 1, greater importance is given to purity. + Defaults to 1. + + See also + -------- + pyannote.metrics.segmentation.SegmentationPurity + pyannote.metrics.segmentation.SegmentationCoverage + pyannote.metrics.base.f_measure + """ + + def __init__(self, tolerance=0.500, beta=1, **kwargs): + super(SegmentationPurityCoverageFMeasure, self).__init__(tolerance=tolerance, **kwargs) + self.beta = beta + + def _process(self, reference, hypothesis): + reference, hypothesis = self._preprocess(reference, hypothesis) + + detail = self.init_components() + + # cooccurrence matrix coverage + K = reference * hypothesis + detail[CVG_TOTAL] = np.sum(K).item() + detail[CVG_INTER] = np.sum(np.max(K, axis=1)).item() + + # cooccurrence matrix purity + detail[PTY_TOTAL] = detail[CVG_TOTAL] + detail[PTY_INTER] = np.sum(np.max(K, axis=0)).item() + + return detail + + def compute_components(self, reference, hypothesis, **kwargs): + return self._process(reference, hypothesis) + + def compute_metric(self, detail): + _, _, value = self.compute_metrics(detail=detail) + return value + + def compute_metrics(self, detail=None): + detail = self.accumulated_ if detail is None else detail + + purity = 1.0 if detail[PTY_TOTAL] == 0.0 else detail[PTY_INTER] / detail[PTY_TOTAL] + + coverage = 1.0 if detail[CVG_TOTAL] == 0.0 else detail[CVG_INTER] / detail[CVG_TOTAL] + + return purity, coverage, f_measure(purity, coverage, beta=self.beta) + + @classmethod + def metric_name(cls): + return PURITY_COVERAGE_NAME + + @classmethod + def metric_components(cls): + return [PTY_TOTAL, PTY_INTER, CVG_TOTAL, CVG_INTER] + + +class SegmentationPrecision(UEMSupportMixin, BaseMetric): + """Segmentation precision + + >>> from pyannote.core import Timeline, Segment + >>> from pyannote.metrics.segmentation import SegmentationPrecision + >>> precision = SegmentationPrecision() + + >>> reference = Timeline() + >>> reference.add(Segment(0, 1)) + >>> reference.add(Segment(1, 2)) + >>> reference.add(Segment(2, 4)) + + >>> hypothesis = Timeline() + >>> hypothesis.add(Segment(0, 1)) + >>> hypothesis.add(Segment(1, 2)) + >>> hypothesis.add(Segment(2, 3)) + >>> hypothesis.add(Segment(3, 4)) + >>> precision(reference, hypothesis) + 0.6666666666666666 + + >>> hypothesis = Timeline() + >>> hypothesis.add(Segment(0, 4)) + >>> precision(reference, hypothesis) + 1.0 + + """ + + @classmethod + def metric_name(cls): + return PRECISION_NAME + + @classmethod + def metric_components(cls): + return [PR_MATCHES, PR_BOUNDARIES] + + def __init__(self, tolerance=0.0, **kwargs): + + super(SegmentationPrecision, self).__init__(**kwargs) + self.tolerance = tolerance + + def compute_components(self, reference, hypothesis, **kwargs): + + # extract timeline if needed + if isinstance(reference, Annotation): + reference = reference.get_timeline() + if isinstance(hypothesis, Annotation): + hypothesis = hypothesis.get_timeline() + + detail = self.init_components() + + # number of matches so far... + nMatches = 0.0 # make sure it is a float (for later ratio) + + # number of boundaries in reference and hypothesis + N = len(reference) + M = len(hypothesis) + + # number of boundaries in hypothesis + detail[PR_BOUNDARIES] = M + + # corner case (no boundary in hypothesis or in reference) + if M == 0 or N == 0: + detail[PR_MATCHES] = 0.0 + return detail + + # Reference and hypothesis boundaries + # note - we have changed this to accept all starts of segments, not all bar one ends of + # segments. We define the speaker change point as when a speaker turn starts, not where + # it finishes. + refBoundaries = [segment.start for segment in reference] + hypBoundaries = [segment.start for segment in hypothesis] + + # temporal delta between all pairs of boundaries + delta = np.zeros((N, M)) + for r, refBoundary in enumerate(refBoundaries): + for h, hypBoundary in enumerate(hypBoundaries): + delta[r, h] = abs(refBoundary - hypBoundary) + + # make sure boundaries too far apart from each other cannot be matched + # (this is what np.inf is used for) + delta[np.where(delta > self.tolerance)] = np.inf + + # h always contains the minimum value in delta matrix + # h == np.inf means that no boundary can be matched + h = np.amin(delta) + + # while there are still boundaries to match + while h < np.inf: + # increment match count + nMatches += 1 + + # find boundaries to match + k = np.argmin(delta) + i = k // M + j = k % M + + # make sure they cannot be matched again + delta[i, :] = np.inf + delta[:, j] = np.inf + + # update minimum value in delta + h = np.amin(delta) + + detail[PR_MATCHES] = nMatches + return detail + + def compute_metric(self, detail): + + numerator = detail[PR_MATCHES] + denominator = detail[PR_BOUNDARIES] + + if denominator == 0.0: + if numerator == 0: + return 1.0 + else: + raise ValueError("") + else: + return numerator / denominator + + +class SegmentationRecall(SegmentationPrecision): + """Segmentation recall + + >>> from pyannote.core import Timeline, Segment + >>> from pyannote.metrics.segmentation import SegmentationRecall + >>> recall = SegmentationRecall() + + >>> reference = Timeline() + >>> reference.add(Segment(0, 1)) + >>> reference.add(Segment(1, 2)) + >>> reference.add(Segment(2, 4)) + + >>> hypothesis = Timeline() + >>> hypothesis.add(Segment(0, 1)) + >>> hypothesis.add(Segment(1, 2)) + >>> hypothesis.add(Segment(2, 3)) + >>> hypothesis.add(Segment(3, 4)) + >>> recall(reference, hypothesis) + 1.0 + + >>> hypothesis = Timeline() + >>> hypothesis.add(Segment(0, 4)) + >>> recall(reference, hypothesis) + 0.0 + + """ + + @classmethod + def metric_name(cls): + return RECALL_NAME + + def compute_components(self, reference, hypothesis, **kwargs): + return super(SegmentationRecall, self).compute_components(hypothesis, reference) diff --git a/metrics/diarization/sm_diarization_metrics/metrics/spotting.py b/metrics/diarization/sm_diarization_metrics/metrics/spotting.py new file mode 100644 index 0000000..81881f4 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/spotting.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2017-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +import sys + +import numpy as np +from pyannote.core import Annotation, Segment, SlidingWindowFeature + +from .base import BaseMetric +from .binary_classification import det_curve + + +class LowLatencySpeakerSpotting(BaseMetric): + """Evaluation of low-latency speaker spotting (LLSS) systems + + LLSS systems can be evaluated in two ways: with fixed or variable latency. + + * When latency is fixed a priori (default), only scores reported by the + system within the requested latency range are considered. Varying the + detection threshold has no impact on the actual latency of the system. It + only impacts the detection performance. + + * In variable latency mode, the whole stream of scores is considered. + Varying the detection threshold will impact both the detection performance + and the detection latency. Each trial will result in the alarm being + triggered with a different latency. In case the alarm is not triggered at + all (missed detection), the latency is arbitrarily set to the value one + would obtain if it were triggered at the end of the last target speech + turn. The reported latency is the average latency over all target trials. + + Parameters + ---------- + latencies : float iterable, optional + Switch to fixed latency mode, using provided `latencies`. + Defaults to [1, 5, 10, 30, 60] (in seconds). + thresholds : float iterable, optional + Switch to variable latency mode, using provided detection `thresholds`. + Defaults to fixed latency mode. + """ + + @classmethod + def metric_name(cls): + return "Low-latency speaker spotting" + + @classmethod + def metric_components(cls): + return {"target": 0.0} + + def __init__(self, thresholds=None, latencies=None): + super(LowLatencySpeakerSpotting, self).__init__() + + if thresholds is None and latencies is None: + latencies = [1, 5, 10, 30, 60] + + if thresholds is not None and latencies is not None: + raise ValueError("One must choose between fixed and variable latency.") + + if thresholds is not None: + self.thresholds = np.sort(thresholds) + + if latencies is not None: + latencies = np.sort(latencies) + + self.latencies = latencies + + def compute_metric(self, detail): + return None + + def _fixed_latency(self, reference, timestamps, scores): + + if not reference: + target_trial = False + spk_score = np.max(scores) * np.ones((len(self.latencies), 1)) + abs_score = spk_score + + else: + target_trial = True + + # cumulative target speech duration after each speech turn + total = np.cumsum([segment.duration for segment in reference]) + + # maximum score in timerange [0, t] + # where t is when latency is reached + spk_score = [] + abs_score = [] + + # index of speech turn when given latency is reached + for i, latency in zip(np.searchsorted(total, self.latencies), self.latencies): + + # maximum score in timerange [0, t] + # where t is when latency is reached + try: + t = reference[i].end - (total[i] - latency) + up_to = np.searchsorted(timestamps, t) + if up_to < 1: + s = -sys.float_info.max + else: + s = np.max(scores[:up_to]) + except IndexError: + s = np.max(scores) + spk_score.append(s) + + # maximum score in timerange [0, t + latency] + # where t is when target speaker starts speaking + t = reference[0].start + latency + + up_to = np.searchsorted(timestamps, t) + if up_to < 1: + s = -sys.float_info.max + else: + s = np.max(scores[:up_to]) + abs_score.append(s) + + spk_score = np.array(spk_score).reshape((-1, 1)) + abs_score = np.array(abs_score).reshape((-1, 1)) + + return { + "target": target_trial, + "speaker_latency": self.latencies, + "spk_score": spk_score, + "absolute_latency": self.latencies, + "abs_score": abs_score, + } + + def _variable_latency(self, reference, timestamps, scores, **kwargs): + + # pre-compute latencies + speaker_latency = np.NAN * np.ones((len(timestamps), 1)) + absolute_latency = np.NAN * np.ones((len(timestamps), 1)) + if isinstance(reference, Annotation): + reference = reference.get_timeline(copy=False) + if reference: + first_time = reference[0].start + for i, t in enumerate(timestamps): + so_far = Segment(first_time, t) + speaker_latency[i] = reference.crop(so_far).duration() + absolute_latency[i] = max(0, so_far.duration) + # TODO | speed up latency pre-computation + + # for every threshold, compute when (if ever) alarm is triggered + maxcum = (np.maximum.accumulate(scores)).reshape((-1, 1)) + triggered = maxcum > self.thresholds + indices = np.array([np.searchsorted(triggered[:, i], True) for i, _ in enumerate(self.thresholds)]) + + if reference: + + target_trial = True + + absolute_latency = np.take(absolute_latency, indices, mode="clip") + speaker_latency = np.take(speaker_latency, indices, mode="clip") + + # is alarm triggered at all? + positive = triggered[-1, :] + + # in case alarm is not triggered, set absolute latency to duration + # between first and last speech turn of the target speaker... + absolute_latency[~positive] = reference.extent().duration + + # ...and set speaker latency to target's total speech duration + speaker_latency[~positive] = reference.duration() + + else: + + target_trial = False + + # the notion of "latency" is not applicable to non-target trials + absolute_latency = np.NAN + speaker_latency = np.NAN + + return { + "target": target_trial, + "absolute_latency": absolute_latency, + "speaker_latency": speaker_latency, + "score": np.max(scores), + } + + def compute_components(self, reference, hypothesis, **kwargs): + """ + + Parameters + ---------- + reference : Timeline or Annotation + hypothesis : SlidingWindowFeature or (time, score) iterable + """ + + if isinstance(hypothesis, SlidingWindowFeature): + hypothesis = [(window.end, value) for window, value in hypothesis] + timestamps, scores = zip(*hypothesis) + + if self.latencies is None: + return self._variable_latency(reference, timestamps, scores) + + else: + return self._fixed_latency(reference, timestamps, scores) + + @property + def absolute_latency(self): + latencies = [trial["absolute_latency"] for _, trial in self if trial["target"]] + return np.nanmean(latencies, axis=0) + + @property + def speaker_latency(self): + latencies = [trial["speaker_latency"] for _, trial in self if trial["target"]] + return np.nanmean(latencies, axis=0) + + def det_curve(self, cost_miss=100, cost_fa=1, prior_target=0.01, return_latency=False): + """DET curve + + Parameters + ---------- + cost_miss : float, optional + Cost of missed detections. Defaults to 100. + cost_fa : float, optional + Cost of false alarms. Defaults to 1. + prior_target : float, optional + Target trial prior. Defaults to 0.5. + return_latency : bool, optional + Set to True to return latency. + Has no effect when latencies are given at initialization time. + + Returns + ------- + thresholds : numpy array + Detection thresholds + fpr : numpy array + False alarm rate + fnr : numpy array + False rejection rate + eer : float + Equal error rate + cdet : numpy array + Cdet cost function + speaker_latency : numpy array + absolute_latency : numpy array + Speaker and absolute latency when return_latency is set to True. + """ + + if self.latencies is None: + + y_true = np.array([trial["target"] for _, trial in self]) + scores = np.array([trial["score"] for _, trial in self]) + fpr, fnr, thresholds, eer = det_curve(y_true, scores, distances=False) + fpr, fnr, thresholds = fpr[::-1], fnr[::-1], thresholds[::-1] + cdet = cost_miss * fnr * prior_target + cost_fa * fpr * (1.0 - prior_target) + + if return_latency: + # needed to align the thresholds used in the DET curve + # with (self.)thresholds used to compute latencies. + indices = np.searchsorted(thresholds, self.thresholds, side="left") + + thresholds = np.take(thresholds, indices, mode="clip") + fpr = np.take(fpr, indices, mode="clip") + fnr = np.take(fnr, indices, mode="clip") + cdet = np.take(cdet, indices, mode="clip") + return thresholds, fpr, fnr, eer, cdet, self.speaker_latency, self.absolute_latency + + else: + return thresholds, fpr, fnr, eer, cdet + + else: + + y_true = np.array([trial["target"] for _, trial in self]) + spk_scores = np.array([trial["spk_score"] for _, trial in self]) + abs_scores = np.array([trial["abs_score"] for _, trial in self]) + + result = {} + for key, scores in {"speaker": spk_scores, "absolute": abs_scores}.items(): + + result[key] = {} + + for i, latency in enumerate(self.latencies): + fpr, fnr, theta, eer = det_curve(y_true, scores[:, i], distances=False) + fpr, fnr, theta = fpr[::-1], fnr[::-1], theta[::-1] + cdet = cost_miss * fnr * prior_target + cost_fa * fpr * (1.0 - prior_target) + result[key][latency] = theta, fpr, fnr, eer, cdet + + return result diff --git a/metrics/diarization/sm_diarization_metrics/metrics/utils.py b/metrics/diarization/sm_diarization_metrics/metrics/utils.py new file mode 100644 index 0000000..aa0dabe --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/utils.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2012-2019 CNRS + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# AUTHORS +# Hervé BREDIN - http://herve.niderb.fr + +from pyannote.core import Segment, Timeline + + +class UEMSupportMixin: + """Provides 'uemify' method with optional (à la NIST) collar""" + + def extrude(self, uem, reference, collar=0.0, skip_overlap=False): + """Extrude reference boundary collars from uem + + reference |----| |--------------| |-------------| + uem |---------------------| |-------------------------------| + extruded |--| |--| |---| |-----| |-| |-----| |-----------| |-----| + + Parameters + ---------- + uem : Timeline + Evaluation map. + reference : Annotation + Reference annotation. + collar : float, optional + When provided, set the duration of collars centered around + reference segment boundaries that are extruded from both reference + and hypothesis. Defaults to 0. (i.e. no collar). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + + Returns + ------- + extruded_uem : Timeline + """ + + if collar == 0.0 and not skip_overlap: + return uem + + collars, overlap_regions = [], [] + + # build list of collars if needed + if collar > 0.0: + # iterate over all segments in reference + for segment in reference.itersegments(): + + # add collar centered on start time + t = segment.start + collars.append(Segment(t - 0.5 * collar, t + 0.5 * collar)) + + # add collar centered on end time + t = segment.end + collars.append(Segment(t - 0.5 * collar, t + 0.5 * collar)) + + # build list of overlap regions if needed + if skip_overlap: + # iterate over pair of intersecting segments + for (segment1, track1), (segment2, track2) in reference.co_iter(reference): + if segment1 == segment2 and track1 == track2: + continue + # add their intersection + overlap_regions.append(segment1 & segment2) + + segments = collars + overlap_regions + + return Timeline(segments=segments).support().gaps(support=uem) + + def common_timeline(self, reference, hypothesis): + """Return timeline common to both reference and hypothesis + + reference |--------| |------------| |---------| |----| + hypothesis |--------------| |------| |----------------| + timeline |--|-----|----|---|-|------| |-|---------|----| |----| + + Parameters + ---------- + reference : Annotation + hypothesis : Annotation + + Returns + ------- + timeline : Timeline + """ + timeline = reference.get_timeline(copy=True) + timeline.update(hypothesis.get_timeline(copy=False)) + return timeline.segmentation() + + def project(self, annotation, timeline): + """Project annotation onto timeline segments + + reference |__A__| |__B__| + |____C____| + + timeline |---|---|---| |---| + + projection |_A_|_A_|_C_| |_B_| + |_C_| + + Parameters + ---------- + annotation : Annotation + timeline : Timeline + + Returns + ------- + projection : Annotation + """ + projection = annotation.empty() + timeline_ = annotation.get_timeline(copy=False) + for segment_, segment in timeline_.co_iter(timeline): + for track_ in annotation.get_tracks(segment_): + track = projection.new_track(segment, candidate=track_) + projection[segment, track] = annotation[segment_, track_] + return projection + + def uemify( + self, reference, hypothesis, uem=None, collar=0.0, skip_overlap=False, returns_uem=False, returns_timeline=False + ): + """Crop 'reference' and 'hypothesis' to 'uem' support + + Parameters + ---------- + reference, hypothesis : Annotation + Reference and hypothesis annotations. + uem : Timeline, optional + Evaluation map. + collar : float, optional + When provided, set the duration of collars centered around + reference segment boundaries that are extruded from both reference + and hypothesis. Defaults to 0. (i.e. no collar). + skip_overlap : bool, optional + Set to True to not evaluate overlap regions. + Defaults to False (i.e. keep overlap regions). + returns_uem : bool, optional + Set to True to return extruded uem as well. + Defaults to False (i.e. only return reference and hypothesis) + returns_timeline : bool, optional + Set to True to oversegment reference and hypothesis so that they + share the same internal timeline. + + Returns + ------- + reference, hypothesis : Annotation + Extruded reference and hypothesis annotations + uem : Timeline + Extruded uem (returned only when 'returns_uem' is True) + timeline : Timeline: + Common timeline (returned only when 'returns_timeline' is True) + """ + + # when uem is not provided, use the union of reference and hypothesis + # extents -- and warn the user about that. + if uem is None: + r_extent = reference.get_timeline().extent() + h_extent = hypothesis.get_timeline().extent() + extent = r_extent | h_extent + uem = Timeline(segments=[extent] if extent else [], uri=reference.uri) + + # Warning disabled for now, as this is the standard use-case + # warnings.warn( + # "'uem' was approximated by the union of 'reference' " + # "and 'hypothesis' extents.") + + # extrude collars (and overlap regions) from uem + uem = self.extrude(uem, reference, collar=collar, skip_overlap=skip_overlap) + + # extrude regions outside of uem + reference = reference.crop(uem, mode="intersection") + hypothesis = hypothesis.crop(uem, mode="intersection") + + # project reference and hypothesis on common timeline + if returns_timeline: + timeline = self.common_timeline(reference, hypothesis) + reference = self.project(reference, timeline) + hypothesis = self.project(hypothesis, timeline) + + result = (reference, hypothesis) + if returns_uem: + result += (uem,) + + if returns_timeline: + result += (timeline,) + + return result diff --git a/metrics/diarization/sm_diarization_metrics/metrics/words.py b/metrics/diarization/sm_diarization_metrics/metrics/words.py new file mode 100644 index 0000000..de96f14 --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/metrics/words.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# encoding: utf-8 + +# The MIT License (MIT) + +# Copyright (c) 2021 Speechmatics + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""This module is original code from Speechmatics and contains +functions to produce word level diarization metrics. +""" + +from .base import BaseMetric +from .matcher import HungarianMapper +from .utils import UEMSupportMixin + +WDER_NAME = "word diarization error rate" + +WDER_TOTAL = "number of words" +WDER_INCORRECT = "number of incorrect words" +WDER_WORD_RESULTS = "word by word results" + + +def get_overlap(a, b): + """Return duration overlap beteween two passed Segments""" + overlap = max(0, min(a.end, b.end) - max(a.start, b.start)) + return overlap + + +def compute_word_diarization_error_rate(reference, hypothesis_mapped, unknown_label): + """For each hypothesis word determine if it's correct or incorrect, and return metrics""" + nwords = 0 + incorrect = 0 + hyp_iter = hypothesis_mapped.itertracks(yield_label=True) + ref_iter = reference.itertracks(yield_label=True) + word_results = [] + current_ref = next(ref_iter, None) + + # Go through each word in the hypothesis + for hyp in hyp_iter: + nwords += 1 + word_correct = False + + # Compare to reference word(s), to find the one that has the largest overlap + # Note: only check correctness if label is not the "unknown speaker" + if hyp[2] != unknown_label and current_ref is not None: + max_overlap = get_overlap(hyp[0], current_ref[0]) + max_label = current_ref[2] + while hyp[0].end > current_ref[0].end: + # Move on to next reference + current_ref = next(ref_iter, None) + if current_ref is None: + break + current_overlap = get_overlap(hyp[0], current_ref[0]) + if current_overlap > max_overlap: + max_overlap = current_overlap + max_label = current_ref[2] + + # For the word with the largest overlap, do the labels match? + if max_overlap > 0.0: + if max_label == hyp[2]: + word_correct = True + + # Store results + word_results.append((hyp[0].start, hyp[0].end, hyp[2], word_correct)) + if not word_correct: + incorrect += 1 + + return nwords, incorrect, word_results + + +class WordDiarizationErrorRate(UEMSupportMixin, BaseMetric): + """Word level diarization error rate""" + + @classmethod + def metric_name(cls): + return WDER_NAME + + @classmethod + def metric_components(cls): + return [WDER_TOTAL, WDER_INCORRECT] + + def __init__(self, **kwargs): + super(WordDiarizationErrorRate, self).__init__(**kwargs) + self.mapper_ = HungarianMapper() + self.unknown_label = "UU" + + def set_unknown_label(self, label): + """Set the label used to denote Unknown speaker in the hypothesis""" + self.unknown_label = label + + def optimal_mapping(self, reference, hypothesis, uem=None): + """Optimal label mapping between reference and hypothesis""" + reference, hypothesis = self.uemify(reference, hypothesis, uem=uem) + return self.mapper_(hypothesis, reference) + + def compute_components(self, reference, hypothesis, uem=None, **kwargs): + """Compute the mapping, and determine the word correctness rate""" + + detail = self.init_components() + + # make sure reference and hypothesis only contains string labels ('A', 'B', ...) + reference = reference.rename_labels(generator="string") + + # make sure hypothesis only contains integer labels (1, 2, ...) + hypothesis = hypothesis.rename_labels(generator="int") + + # optimal (int --> str) mapping + mapping = self.optimal_mapping(reference, hypothesis, uem=uem) + hypothesis_mapped = hypothesis.rename_labels(mapping=mapping) + + # Compute the word accuracy + nwords, incorrect, word_results = compute_word_diarization_error_rate( + reference, hypothesis_mapped, self.unknown_label + ) + detail[WDER_TOTAL] = nwords + detail[WDER_INCORRECT] = incorrect + detail[WDER_WORD_RESULTS] = word_results + + return detail + + def compute_metric(self, detail): + """Return metric from details""" + numerator = detail[WDER_INCORRECT] + denominator = detail[WDER_TOTAL] + if denominator == 0.0: + if numerator == 0: + return 1.0 + else: + raise ValueError("") + else: + return numerator / denominator diff --git a/metrics/diarization/sm_diarization_metrics/sm_diarisation_metrics.pdf b/metrics/diarization/sm_diarization_metrics/sm_diarisation_metrics.pdf new file mode 100755 index 0000000000000000000000000000000000000000..36121b1edbdd7a9b6ef38a6ad1823f4b6058e590 GIT binary patch literal 299751 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ⅆB@o9guT| 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

!kLo=r&_-kd8EAm5ZV#W4wWm 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%0AeMPvBCPm!> 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 YgBy3RIom*Q58CZ;#i6IR|5fq-2hK`G)&Kwi literal 0 HcmV?d00001 diff --git a/metrics/diarization/sm_diarization_metrics/utils.py b/metrics/diarization/sm_diarization_metrics/utils.py new file mode 100644 index 0000000..82647ed --- /dev/null +++ b/metrics/diarization/sm_diarization_metrics/utils.py @@ -0,0 +1,256 @@ +# The MIT License (MIT) + +# Copyright (c) 2021 Speechmatics + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""This module provides support functionality for dealing with different input format, etc.""" + +import json +import sys +from argparse import ArgumentParser +from os.path import join +from types import SimpleNamespace +from typing import Dict, List + +from pyannote.core import Annotation, Segment + +from .metrics.diarization import DiarizationErrorRate + + +def time_to_frame(time_seconds, fps=100): + """Convert from seconds to frames""" + return round(time_seconds * fps) + + +def load_dbl(filename): + """Load the lines from a dbl file (1 entry per line)""" + entries = [] + with open(filename) as infile: + entries = [line.strip() for line in infile.readlines()] + return entries + + +def complete_filename(basename, directory, extension): + """Complete a filename as returned from dbl, adding directory and extension""" + filename = basename + extension + if directory != "": + filename = join(directory, filename) + return filename + + +def load_lab_file(filename): + """Load label file formatted input file (