Files
sphinxtrain/scripts/decode/word_align.pl

406 lines
12 KiB
Perl

#!/usr/bin/perl -w
# word_align.pl - Calculate word error and accuracy for a recognition
# hypothesis file vs. a reference transcription
#
# Written by David Huggins-Daines <dhuggins@cs.cmu.edu> for Speech
# Recognition and Understanding 11-751, Carnegie Mellon University,
# October 2004.
use strict;
use Pod::Usage;
use Getopt::Long;
use open ':encoding(utf8)';
binmode(STDOUT, ":utf8");
use constant INS => 1;
use constant DEL => 2;
use constant MATCH => 3;
use constant SUBST => 4;
use constant BIG_NUMBER => 1e50;
use vars qw($Help $Verbose $CER $IgnoreUttID $SkipComments $RefHypMatch);
# Defaults
$IgnoreUttID = 0;
$SkipComments = 0;
$RefHypMatch = 1;
GetOptions(
'help|?' => \$Help,
'verbose|v' => \$Verbose,
'cer|c' => \$CER,
'ignore-uttid|i' => \$IgnoreUttID,
'comments!' => \$SkipComments,
'ref-hyp-match!' => \$RefHypMatch
) or pod2usage(1);
pod2usage(1) if $Help;
pod2usage(2) unless @ARGV == 2;
my %hyphash;
my ($ref, $hyp) = @ARGV;
my ($total_cost, $total_words, $total_hyp);
my ($total_ins, $total_del, $total_subst, $total_match);
# Build hypothesis hash (lookup by uttid)
open HYP, "<$hyp" or die "Failed to open $hyp: $!";
while (defined(my $hyp_utt=<HYP>)){
my $hyp_uttid;
($hyp_utt, $hyp_uttid) = s3_magic_norm($hyp_utt);
$hyphash{$hyp_uttid} = "$hyp_utt ($hyp_uttid)";
}
close HYP;
die "No hypotheses in: $hyp" unless %hyphash;
open REF, "<$ref" or die "Failed to open $ref: $!";
open HYP, "<$hyp" or die "Failed to open $hyp: $!";
# Main loop
while (defined(my $ref_utt = <REF>)) {
my (@res, $ref_uttid, $hyp_utt, $hyp_uttid);
last unless defined $ref_utt;
# Normalize reference utterance
@res = s3_magic_norm($ref_utt);
# Skip empty and commented lines; s3_magic_norm() returns undef.
next unless grep defined, @res;
($ref_utt, $ref_uttid) = @res;
if ($IgnoreUttID) {
$hyp_utt = <HYP>;
die "UTTID is ignored when matching reference and hypothesis ",
"files and line count of files don't match. If UTTID is ignored, ",
"then files should match not only in line count, but also line ",
"by line.\n"
unless defined($hyp_utt);
} else {
$hyp_utt = $hyphash{$ref_uttid};
if (!defined($hyp_utt)) {
next unless $RefHypMatch;
die "UTTID '$ref_uttid' from the reference file '$ref' (line $.) ",
"could not be found in the hypothesis file '$hyp'.\n";
}
}
# Normalize hypothesis utterance
($hyp_utt, $hyp_uttid) = s3_magic_norm($hyp_utt);
# Redundant: if we are here after the above check, then uttid matches.
# if(! $IgnoreUttID) {
# die "Utterance ID mismatch on line $.: $ref_uttid != $hyp_uttid"
# unless $ref_uttid eq $hyp_uttid;
# }
# Split the text into an array of words
my @ref_words = split ' ', $ref_utt;
my @hyp_words = split ' ', $hyp_utt;
if ($CER) {
# Split the text into an array of characters
@ref_words = map { split "" } @ref_words;
@hyp_words = map { split "" } @hyp_words;
}
# Initialize the alignment and backtrace matrices
my (@align_matrix, @backtrace_matrix);
initialize(\@ref_words, \@hyp_words, \@align_matrix, \@backtrace_matrix);
# Do DP alignment maintaining backtrace pointers
my $cost = align(\@ref_words, \@hyp_words,
\@align_matrix, \@backtrace_matrix);
# Find the backtrace
my ($alignment, $ins, $del, $subst, $match) =
backtrace(\@ref_words, \@hyp_words,
\@align_matrix, \@backtrace_matrix);
# Format the alignment nicely
my ($ref_align, $hyp_align) = ("", "");
foreach (@$alignment) {
my ($ref, $hyp) = @$_;
my $width = 0;
if (defined($ref) and defined($hyp)) {
if ($CER or
($ref =~ /\p{InCJKUnifiedIdeographs}/ or
$ref =~ /\p{Han}/ or
$hyp =~ /\p{Han}/)) {
# Assume this is Chinese, no capitalization
# so put ** around errors
if ($ref ne $hyp) {
$ref = "*$ref*";
$hyp = "*$hyp*";
}
} elsif ($ref eq $hyp) {
# Capitalize errors (they already are...), lowercase matches
$ref = lc $ref;
$hyp = lc $hyp;
}
}
# Replace deletions with ***
foreach ($ref, $hyp) { $_ = "***" unless defined $_ };
# Find the width of this column
foreach ($ref, $hyp) { $width = length if length > $width };
$width = 3 if $width < 3; # Make it long enough for ***
# Space out the words and concatenate them to the output
$ref_align .= sprintf("%-*s ", $width, $ref);
$hyp_align .= sprintf("%-*s ", $width, $hyp);
}
$ref_uttid = "" unless defined $ref_uttid; # avoid warnings
print "$ref_align ($ref_uttid)\n$hyp_align ($hyp_uttid)\n";
# Print out the word error and accuracy rates
my $acc = @ref_words == 0 ? 0 : $match/@ref_words;
my $error = @ref_words == 0 ? 1 : $cost/@ref_words;
printf("Words: %d Correct: %d Errors: %d Percent correct = %.2f%% " .
"Error = %.2f%% Accuracy = %.2f%%\n",
scalar(@ref_words), $match, $cost, $acc*100,
$error*100, 100-$error*100);
print "Insertions: $ins Deletions: $del Substitutions: $subst\n";
$total_cost += $cost;
$total_ins += $ins;
$total_del += $del;
$total_subst += $subst;
$total_match += $match;
$total_hyp += @hyp_words;
$total_words += @ref_words;
}
# Print out the total word error and accuracy rates
my ($acc, $error);
if ($total_words == 0) {
$acc = $total_match/$total_hyp;
$error = $total_cost/$total_hyp;
} else {
$acc = $total_match/$total_words;
$error = $total_cost/$total_words;
}
printf("TOTAL Words: %d Correct: %d Errors: %d\n" .
"TOTAL Percent correct = %.2f%% Error = %.2f%% Accuracy = %.2f%%\n",
$total_words, $total_match, $total_cost,
$acc*100, $error*100, 100-$error*100);
print("TOTAL Insertions: $total_ins Deletions: $total_del ",
"Substitutions: $total_subst\n");
# This function normalizes a line of a match file.
sub s3_magic_norm{
my ($word)=@_;
my $uttid;
# Skip empty lines
return undef if $word =~ /^\s*$/;
# Skip commented lines
if ($SkipComments) {
return undef if $word =~ /^\s*#/;
}
# Remove line endings
$word =~ s/[\n\r]+$//; # the agnostic way...
# This computes the uttid and remove it from a line.
$word =~ s/\(([^) ]+)[^)]*\)$// ;
$uttid = $1;
# Normalize case
$word = uc $word;
# Remove filler words and context cues
$word =~ s/<[^>]+>//g;
$word =~ s/\+\+[^+]+\+\+//g;
$word =~ s/\+[^+]+\+//g;
# Remove alternative pronunciations
$word =~ s/\([1-9]\)//g;
# Remove class tags
$word =~ s/:\S+//g;
# Split apart compound words and acronyms
$word =~ tr/-_./ /;
return ($word, $uttid);
}
sub initialize {
my ($ref_words, $hyp_words, $align_matrix, $backtrace_matrix) = @_;
# All initial costs along the j axis are insertions
for (my $j = 0; $j <= @$hyp_words; ++$j) {
$$align_matrix[0][$j] = $j;
}
for (my $j = 0; $j <= @$hyp_words; ++$j) {
$$backtrace_matrix[0][$j] = INS;
}
# All initial costs along the i axis are deletions
for (my $i = 0; $i <= @$ref_words; ++$i) {
$$align_matrix[$i][0] = $i;
}
for (my $i = 0; $i <= @$ref_words; ++$i) {
$$backtrace_matrix[$i][0] = DEL;
}
}
sub align {
my ($ref_words, $hyp_words, $align_matrix, $backtrace_matrix) = @_;
for (my $i = 1; $i <= @$ref_words; ++$i) {
for (my $j = 1; $j <= @$hyp_words; ++$j) {
# Find insertion, deletion, substitution scores
my ($ins, $del, $subst);
# Cost of a substitution (0 if they are equal)
my $cost = $$ref_words[$i-1] ne $$hyp_words[$j-1];
# Find insertion, deletion, substitution costs
$ins = $$align_matrix[$i][$j-1] + 1;
$del = $$align_matrix[$i-1][$j] + 1;
$subst = $$align_matrix[$i-1][$j-1] + $cost;
print "Costs at $i $j: INS $ins DEL $del SUBST $subst\n"
if $Verbose;
# Get the minimum one
my $min = BIG_NUMBER;
foreach ($ins, $del, $subst) {
if ($_ < $min) {
$min = $_;
}
}
$$align_matrix[$i][$j] = $min;
# If the costs are equal, prefer match or substitution
# (keep the path diagonal).
if ($min == $subst) {
$$backtrace_matrix[$i][$j] = MATCH + $cost;
print(($cost ? "SUBSTITUTION" : "MATCH"),
"($$ref_words[$i-1] <=> $$hyp_words[$j-1])\n")
if $Verbose;
}
elsif ($min == $ins) {
$$backtrace_matrix[$i][$j] = INS;
print "INSERTION (0 => $$hyp_words[$j-1])\n" if $Verbose;
}
elsif ($min == $del) {
$$backtrace_matrix[$i][$j] = DEL;
print "DELETION ($$ref_words[$i-1] => 0)\n" if $Verbose;
}
}
}
return $$align_matrix[@$ref_words][@$hyp_words];
}
sub backtrace {
my ($ref_words, $hyp_words, $align_matrix, $backtrace_matrix) = @_;
# Backtrace to find number of ins/del/subst
my @alignment;
my $i = @$ref_words;
my $j = @$hyp_words;
my ($inspen, $delpen, $substpen, $match) = (0, 0, 0, 0);
while (!($i == 0 and $j == 0)) {
my $pointer = $$backtrace_matrix[$i][$j];
print "Cost at $i $j: $$align_matrix[$i][$j]\n" if $Verbose;
if ($pointer == INS) {
print "INSERTION (0 => $$hyp_words[$j-1])" if $Verbose;
# Append the pair 0:hyp[j] to the front of the alignment
unshift @alignment, [undef, $$hyp_words[$j-1]];
++$inspen;
--$j;
print " - moving to $i $j\n" if $Verbose;
}
elsif ($pointer == DEL) {
print "DELETION ($$ref_words[$i-1] => 0)" if $Verbose;
# Append the pair ref[i]:0 to the front of the alignment
unshift @alignment, [$$ref_words[$i-1], undef];
++$delpen;
--$i;
print " - moving to $i $j\n" if $Verbose;
}
elsif ($pointer == MATCH) {
print "MATCH ($$ref_words[$i-1] <=> $$hyp_words[$j-1])" if $Verbose;
# Append the pair ref[i]:hyp[j] to the front of the alignment
unshift @alignment, [$$ref_words[$i-1], $$hyp_words[$j-1]];
++$match;
--$j;
--$i;
print " - moving to $i $j\n" if $Verbose;
}
elsif ($pointer == SUBST) {
print "SUBSTITUTION ($$ref_words[$i-1] <=> $$hyp_words[$j-1])"
if $Verbose;
# Append the pair ref[i]:hyp[j] to the front of the alignment
unshift @alignment, [$$ref_words[$i-1], $$hyp_words[$j-1]];
++$substpen;
--$j;
--$i;
print " - moving to $i $j\n" if $Verbose;
}
else {
last;
}
}
return (\@alignment, $inspen, $delpen, $substpen, $match);
}
__END__
=head1 NAME
word_align - Align reference and hypothesis files and calculate
recognition accuracy.
=head1 SYNOPSIS
word_align [options] reference_file hypothesis_file
=head1 OPTIONS
=over 8
=item B<--help>, B<-?>
Print a brief help message and exit.
=item B<--verbose>, B<-v>
Print out messages tracing the alignment algorithm.
=item B<--ignore-uttid>, B<-i>
Ignore utterance ID when aligning. This option requires that
reference and hypothesis files should strictly match line by line.
=item B<--cer>, B<-c>
Character based alignment; compute CER (Character Error Rate).
=item B<--comments>, B<--no-comments>
Specifies whenether reference file contains # commented lines which
should be ignored. B<Default>: --no-comments (read all). Useful during
sphinx_batch testing, when corresponing control and reference/transcription
file lines can be handily commented (and are ignored by the decoder, other
processing tools) and then they should also be ignored by this tool.
=item B<--ref-hyp-match>, B<--no-ref-hyp-match>
Specifies whenether reference and hypothesis files should match.
B<Default>: --ref-hyp-match (should match). Non-strict matching is useful
during sphinx_batch partial tests, when hypothesis file contains only part
of original transcription/reference utterances. The opposite still holds:
all utterances of hypothesis file should have a corresponding match in
reference file.
=cut