diff --git a/.gitignore b/.gitignore index 15b8746..f3f2148 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__ .idea/ *idfsim *.swp +trec_eval.9.0/ +*.pt +text/ diff --git a/sm_modified_cnn/.gitignore b/sm_modified_cnn/.gitignore new file mode 100644 index 0000000..411a968 --- /dev/null +++ b/sm_modified_cnn/.gitignore @@ -0,0 +1,5 @@ +*pyc +*.pt +text/ +trained_models/ +trec_eval-8.0/trec_eval.dSYM diff --git a/sm_modified_cnn/README.md b/sm_modified_cnn/README.md new file mode 100644 index 0000000..95f573b --- /dev/null +++ b/sm_modified_cnn/README.md @@ -0,0 +1,78 @@ +## SM model + +#### References: +1. Aliaksei _S_everyn and Alessandro _M_oschitti. 2015. Learning to Rank Short Text Pairs with Convolutional Deep Neural +Networks. In Proceedings of the 38th International ACM SIGIR Conference on Research and Development in Information +Retrieval (SIGIR '15). ACM, New York, NY, USA, 373-382. DOI: http://dx.doi.org/10.1145/2766462.2767738 + + +### Requirements +``` +nltk==3.2.2 +numpy==1.11.3 +pytorch==0.1.12 +``` + +The code uses torchtext for text processing. Set torchtext: +```bash +git clone https://github.com/pytorch/text.git +cd text +python setup.py install +``` + +We use `trec_eval` for evaluation: + +```bash +cd eval +tar -xvf trec_eval.9.0.tar.gz +make +cd .. +``` + +Download the word2vec model from [here] (https://drive.google.com/file/d/0B2u_nClt6NbzUmhOZU55eEo4QWM/view?usp=sharing) +and copy it to the `data/` folder. + +### Training the model + +You can train the SM model for the 4 following configurations: +1. __random__ - the word embedddings are initialized randomly and are tuned during training +2. __static__ - the word embeddings are static (Severyn and Moschitti, SIGIR'15) +3. __non-static__ - the word embeddings are tuned during training +4. __multichannel__ - contains static and non-static channels for question and answer conv layers + +To train on GPU 0 with static configuration: + +```bash +python train.py --mode static --gpu 0 +``` + +NB: pass `--no_cuda` to use CPU + +The trained model will be save to: +``` +saves/static_best_model.pt +``` + +### Testing the model + +``` +python main.py --trained_model saves/TREC/multichannel_best_model.pt +``` + +### Evaluation + +The performance on TrecQA dataset: + +### Best dev + +Metric |rand |static|non-static|multichannel +-------|------|------|----------|------------ +MAP |0.8096|0.8162|0.8387 | 0.8274 +MRR |0.8560|0.8918|0.9058 | 0.8818 + +### Test + +Metric |rand |static|non-static|multichannel +-------|-------|------|----------|------------ +MAP |0.7441 |0.7524|0.7688 |0.7641 +MRR |0.8172 |0.8012|0.8144 |0.8174 \ No newline at end of file diff --git a/sm_modified_cnn/args.py b/sm_modified_cnn/args.py new file mode 100644 index 0000000..038105b --- /dev/null +++ b/sm_modified_cnn/args.py @@ -0,0 +1,28 @@ +from argparse import ArgumentParser + +def get_args(): + parser = ArgumentParser(description="SM CNN") + parser.add_argument('--no_cuda', action='store_false', help='do not use cuda', dest='cuda') + parser.add_argument('--gpu', type=int, default=0) # Use -1 for CPU + parser.add_argument('--epochs', type=int, default=30) + parser.add_argument('--batch_size', type=int, default=64) + parser.add_argument('--mode', type=str, default='static') + parser.add_argument('--lr', type=float, default=1.0) + parser.add_argument('--seed', type=int, default=3435) + parser.add_argument('--dataset', type=str, default='TREC') + parser.add_argument('--resume_snapshot', type=str, default=None) + parser.add_argument('--dev_every', type=int, default=30) + parser.add_argument('--log_every', type=int, default=10) + parser.add_argument('--patience', type=int, default=50) + parser.add_argument('--save_path', type=str, default='saves') + parser.add_argument('--output_channel', type=int, default=100) + parser.add_argument('--filter_width', type=int, default=5) + parser.add_argument('--words_dim', type=int, default=50) + parser.add_argument('--dropout', type=float, default=0.5) + parser.add_argument('--epoch_decay', type=int, default=15) + parser.add_argument('--vector_cache', type=str, default='data/word2vec.trecqa.pt') + parser.add_argument('--trained_model', type=str, default="") + parser.add_argument('--weight_decay',type=float, default=1e-5) + + args = parser.parse_args() + return args diff --git a/sm_modified_cnn/eval/gdeval.pl b/sm_modified_cnn/eval/gdeval.pl new file mode 100755 index 0000000..7ae7572 --- /dev/null +++ b/sm_modified_cnn/eval/gdeval.pl @@ -0,0 +1,340 @@ +#!/usr/bin/perl -w + +# Graded relevance assessment script for the TREC 2010 Web track +# Evalution measures are written to standard output in CSV format. +# +# Currently reports only NDCG and ERR +# (see http://learningtorankchallenge.yahoo.com/instructions.php) + +use constant LOGBASEDIV => log(2.0); + +# gloals +my $QRELS; +my $VERSION = "version 1.3 (Mon Apr 29 20:50:24 EDT 2013)"; +my $MAX_JUDGMENT = 4; # Maximum gain value allowed in qrels file. +my $K = 20; # Reporting depth for results. +my $USAGE = "usage: $0 [options] qrels run\n + options:\n + -c + Average over the complete set of topics in the relevance judgments + instead of the topics in the intersection of relevance judgments + and results.\n + -k value + Non-negative integer depth of ranking to evaluate in range [1,inf]. + Default value is k=@{[($K)]}.\n + -baseline BASELINE_RUN_FILE + Baseline run to use for risk-sensitive evaluation\n + -riskAlpha value + Non-negative Risk sensitivity value to use when doing risk-sensitive + evaluation. A baseline must still be specified. By default 0. + The final weight to downside changes in performance is (1+value).\n"; + + + +use strict 'vars'; + +{ # main block to scope variables + if ($#ARGV >= 0 && ($ARGV[0] eq "-v" || $ARGV[0] eq "-version")) { + print "$0: $VERSION\n"; + exit 0; + } + + my $baselineRun = undef; + my $riskAlpha = 0; + my $cflag = 0; + while ($#ARGV != 1) # should probably replace this with perl's argument parsing + { + if ($#ARGV >= 0 && $ARGV[0] eq "-help") { + print "$USAGE\n"; + exit 0; + } + elsif ($#ARGV >= 2 and ("-c" eq $ARGV[0])) + { + $cflag = 1; + shift @ARGV; + } + elsif ($#ARGV >= 3 and ("-k" eq $ARGV[0])) + { + $K = int($ARGV[1]); + die $USAGE if ($K < 1); +# print STDERR "k=$K\n"; + shift @ARGV; shift @ARGV; + } + elsif ($#ARGV >= 3 and ("-baseline" eq $ARGV[0])) + { + $baselineRun = $ARGV[1]; + shift @ARGV; shift @ARGV; + } + elsif ($#ARGV >= 3 and ("-riskAlpha" eq $ARGV[0])) + { + $riskAlpha = $ARGV[1]; + die $USAGE if ($riskAlpha < 0.0); + shift @ARGV; shift @ARGV; + } + else + { + die $USAGE; + } + } + + die $USAGE unless $#ARGV == 1; + $QRELS = $ARGV[0]; + my $run = $ARGV[1]; + + # Read qrels file, check format, and sort + my @qrels = (); + my %seen = (); + open (QRELS,"<$QRELS") || die "$0: cannot open \"$QRELS\": $!\n"; + while () { + s/[\r\n]//g; + my ($topic, $zero, $docno, $judgment) = split (' '); + $topic =~ s/^.*\-//; + die "$0: format error on line $. of \"$QRELS\"\n" + unless + $topic =~ /^[0-9]+$/ && $zero == 0 + && $judgment =~ /^-?[0-9]+$/ && $judgment <= $MAX_JUDGMENT; + if ($judgment > 0) { + $qrels[$#qrels + 1]= "$topic $docno $judgment"; + $seen{$topic} = 1; + } + } + close (QRELS); + @qrels = sort qrelsOrder (@qrels); + + # Process qrels: store judgments and compute ideal gains + my $topicCurrent = -1; + my %ideal = (); + my @gain = (); + my %judgment = (); + for (my $i = 0; $i <= $#qrels; $i++) { + my ($topic, $docno, $judgment) = split (' ', $qrels[$i]); + if ($topic != $topicCurrent) { + if ($topicCurrent >= 0) { + $ideal{$topicCurrent} = &dcg($K, @gain); + $#gain = -1; + } + $topicCurrent = $topic; + } + next if $judgment < 0; + $judgment{"$topic:$docno"} = $gain[$#gain + 1] = $judgment; + } + if ($topicCurrent >= 0) { + $ideal{$topicCurrent} = &dcg($K, @gain); + $#gain = -1; + } + + # process baseline if doing risk sensitive + my ($baseNDCGByTopic,$baseERRByTopic,$baserunname); + if (defined $baselineRun) + { + ($baseNDCGByTopic,$baseERRByTopic,$baserunname) = processRun($baselineRun,0,\%seen,\%ideal,\%judgment,$cflag,0); + } + + # process main run + processRun($run,1,\%seen,\%ideal,\%judgment,$cflag,defined($baselineRun),$riskAlpha,$baserunname,$baseNDCGByTopic,$baseERRByTopic); + + exit 0; + +} # end main block + +# comparison function for qrels: by topic then judgment +sub qrelsOrder { + my ($topicA, $docnoA, $judgmentA) = split (' ', $a); + my ($topicB, $docnoB, $judgmentB) = split (' ', $b); + + if ($topicA < $topicB) { + return -1; + } elsif ($topicA > $topicB) { + return 1; + } else { + return $judgmentB <=> $judgmentA; + } +} + +# comparison function for runs: by topic then score then docno +sub runOrder { + my ($topicA, $docnoA, $scoreA) = split (' ', $a); + my ($topicB, $docnoB, $scoreB) = split (' ', $b); + + if ($topicA < $topicB) { + return -1; + } elsif ($topicA > $topicB) { + return 1; + } elsif ($scoreA < $scoreB) { + return 1; + } elsif ($scoreA > $scoreB) { + return -1; + } elsif ($docnoA lt $docnoB) { + return 1; + } elsif ($docnoA gt $docnoB) { + return -1; + } else { + return 0; + } +} + +# compute DCG over a sorted array of gain values, reporting at depth $k +sub dcg { + my ($k, @gain) = @_; + my ($i, $score) = (0, 0); + + for ($i = 0; $i <= ($k <= $#gain ? $k - 1 : $#gain); $i++) { + $score += (2**$gain[$i] - 1)/(log ($i + 2)/ +LOGBASEDIV); + } + return $score; +} + +# compute ERR over a sorted array of gain values, reporting at depth $k +sub err { + my ($k, @gain) = @_; + my ($i, $score, $decay, $r); + + $score = 0.0; + $decay = 1.0; + for ($i = 0; $i <= ($k <= $#gain ? $k - 1 : $#gain); $i++) { + $r = (2**$gain[$i] - 1)/(2**$MAX_JUDGMENT); + $score += $r*$decay/($i + 1); + $decay *= (1 - $r); + } + return $score; +} + +sub riskWeighted +{ + my ($run,$base,$alpha) = @_; + if ($run < $base) + { + $run = (1+$alpha) * ($run - $base); + } + else + { + $run = $run - $base; + } + return $run; +} +# compute and report information for current topic +sub topicDone { + my ($printTopic, $runid, $topic, $pndcgTotal, $perrTotal, $ptopics, $pseen, $pideal, + $isRiskSensitive, $riskAlpha, $baseNDCG, $baseERR, @gain) = @_; + my($ndcg, $err) = (0, 0); + if (exists($$pseen{$topic}) and defined($$pseen{$topic}) and $$pseen{$topic}) { + $ndcg = &dcg($K, @gain)/$$pideal{$topic}; + $err = &err ($K, @gain); + $ndcg = riskWeighted($ndcg,$baseNDCG,$riskAlpha) if ($isRiskSensitive); + $err = riskWeighted($err,$baseERR,$riskAlpha) if ($isRiskSensitive); + $$pndcgTotal += $ndcg; + $$perrTotal += $err; + $$ptopics++; + printf("$runid,$topic,%.5f,%.5f\n",$ndcg,$err) if ($printTopic); + return ($ndcg,$err); + } +} + +sub processRun +{ + my ($run,$printTopics,$pseen,$pideal,$pjudgment,$avgOverAllTopics,$isRiskSensitive,$riskAlpha,$baserunname,$baseNDCGByTopic,$baseERRByTopic) = @_; + my $ndcgByTopic = {()}; + my $errByTopic = {()}; + my $runid = "?????"; + my @run = (); + # Read run rile, check format, and sort + open (RUN,"<$run") || die "$0: cannot open \"$run\": $!\n"; + while () { + s/[\r\n]//g; + my ($topic, $q0, $docno, $rank, $score); + ($topic, $q0, $docno, $rank, $score, $runid) = split (' '); + $topic =~ s/^.*\-//; + die "$0: format error on line $. of \"$run\"\n" + unless + $topic =~ /^[0-9]+$/ && $q0 eq "Q0" && $rank =~ /^[0-9]+$/ && $runid; + $run[$#run + 1] = "$topic $docno $score"; + } + + @run = sort runOrder (@run); + + my %processed = (); + foreach my $topic (%$pseen) + { + $processed{$topic} = 0; + } + + if ($isRiskSensitive) + { + $runid = sprintf("%s (rel to. %s, rs=1+a, a=%s)",$runid,$baserunname,$riskAlpha); + } + + # Process runs: compute measures for each topic and average + my $ndcgTotal = 0; + my $errTotal = 0; + my $topics = 0; + print "runid,topic,ndcg\@$K,err\@$K\n" if ($printTopics); + my $topicCurrent = -1; + my @gain = (); + for (my $i = 0; $i <= $#run; $i++) { + my ($topic, $docno, $score) = split (' ', $run[$i]); + if ($topic != $topicCurrent) { + if ($topicCurrent >= 0) { + my ($baseNDCG,$baseERR) = 0; + if ($isRiskSensitive) + { + $baseNDCG = $$baseNDCGByTopic{$topicCurrent} if (exists($$baseNDCGByTopic{$topicCurrent}) and defined($$baseNDCGByTopic{$topicCurrent})); + $baseERR = $$baseERRByTopic{$topicCurrent} if (exists($$baseERRByTopic{$topicCurrent}) and defined($$baseERRByTopic{$topicCurrent})); + } + my ($ndcg,$err) = &topicDone ($printTopics, $runid, $topicCurrent, \$ndcgTotal, \$errTotal, \$topics, + $pseen, $pideal, $isRiskSensitive, $riskAlpha, $baseNDCG, $baseERR, @gain); + $$ndcgByTopic{$topicCurrent} = $ndcg; + $$errByTopic{$topicCurrent} = $err; + $processed{$topicCurrent} = 1; + $#gain = -1; + } + $topicCurrent = $topic; + } + my $j = $$pjudgment{"$topic:$docno"}; + $j = 0 unless $j; + $gain[$#gain + 1] = $j; + } + if ($topicCurrent >= 0) { + my ($baseNDCG,$baseERR) = 0; + if ($isRiskSensitive) + { + $baseNDCG = $$baseNDCGByTopic{$topicCurrent} if (exists($$baseNDCGByTopic{$topicCurrent}) and defined($$baseNDCGByTopic{$topicCurrent})); + $baseERR = $$baseERRByTopic{$topicCurrent} if (exists($$baseERRByTopic{$topicCurrent}) and defined($$baseERRByTopic{$topicCurrent})); + } + my ($ndcg,$err) = &topicDone ($printTopics, $runid, $topicCurrent, \$ndcgTotal, \$errTotal, \$topics, + $pseen, $pideal, $isRiskSensitive, $riskAlpha, $baseNDCG, $baseERR, @gain); + $$ndcgByTopic{$topicCurrent} = $ndcg; + $$errByTopic{$topicCurrent} = $err; + $processed{$topicCurrent} = 1; + $#gain = -1; + } + my $numTopics = $topics; # $topics has the number in the run (at this point) + if ($avgOverAllTopics) + { + $numTopics = scalar(keys %$pseen); # we want denominator to change whenever flag is on but only need to compute differences for risk + if ($isRiskSensitive) + { # need to process any topics that were missing from run + my ($baseNDCG,$baseERR) = 0; + my @gain = (); + foreach my $topicCurrent (sort {$a <=> $b} keys %processed) + { + next if ($processed{$topicCurrent}); + $baseNDCG = $$baseNDCGByTopic{$topicCurrent} if (exists($$baseNDCGByTopic{$topicCurrent}) and defined($$baseNDCGByTopic{$topicCurrent})); + $baseERR = $$baseERRByTopic{$topicCurrent} if (exists($$baseERRByTopic{$topicCurrent}) and defined($$baseERRByTopic{$topicCurrent})); + my ($ndcg,$err) = &topicDone ($printTopics, $runid, $topicCurrent, \$ndcgTotal, \$errTotal, \$topics, + $pseen, $pideal, $isRiskSensitive, $riskAlpha, $baseNDCG, $baseERR, @gain); + } + } + } + + my $ndcgAvg = $ndcgTotal; + my $errAvg = $errTotal; + if ($numTopics > 0) + { + $ndcgAvg /= $numTopics; + $errAvg /= $numTopics; + } + printf "$runid,amean,%.5f,%.5f\n",$ndcgAvg,$errAvg if ($printTopics); + + return ($ndcgByTopic,$errByTopic,$runid); + close(RUN); +} diff --git a/sm_modified_cnn/eval/trec_eval.9.0.tar.gz b/sm_modified_cnn/eval/trec_eval.9.0.tar.gz new file mode 100644 index 0000000..660de16 Binary files /dev/null and b/sm_modified_cnn/eval/trec_eval.9.0.tar.gz differ diff --git a/sm_modified_cnn/evaluate.py b/sm_modified_cnn/evaluate.py new file mode 100644 index 0000000..4460dd8 --- /dev/null +++ b/sm_modified_cnn/evaluate.py @@ -0,0 +1,23 @@ +import shlex +import subprocess + +def evaluate(instances, valid, config): + sorted_instances = sorted(instances, key=lambda x: (x[0])) + with open('{}.{}.run.txt'.format(valid, config), 'w') as run, open('{}.{}.qrel.txt'.format(valid, config), 'w') as qrel: + i = 0 + for instance in sorted_instances: + qid, predicted, score, gold = instance[0], instance[1], instance[2], instance[3] + + # 32.1 0 1 0 0.13309887051582336 smmodel + run.write('{} 0 {} 0 {} sm_model\n'.format(qid, i, score)) + qrel.write('{} 0 {} {}\n'.format(qid, i, gold)) + i += 1 + + pargs = shlex.split("./eval/trec_eval.9.0/trec_eval -m map -m recip_rank {}.{}.qrel.txt {}.{}.run.txt" + .format(valid, config, valid, config)) + p = subprocess.Popen(pargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + pout, perr = p.communicate() + lines = pout.split(b'\n') + map = float(lines[0].strip().split()[-1]) + mrr = float(lines[1].strip().split()[-1]) + return map, mrr \ No newline at end of file diff --git a/sm_modified_cnn/main.py b/sm_modified_cnn/main.py new file mode 100644 index 0000000..50e0d0b --- /dev/null +++ b/sm_modified_cnn/main.py @@ -0,0 +1,98 @@ +import numpy as np +import random +import logging + +import torch +from torchtext import data + +from args import get_args +from trec_dataset import TrecDataset +from evaluate import evaluate + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +ch = logging.StreamHandler() +ch.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(levelname)s - %(message)s') +ch.setFormatter(formatter) +logger.addHandler(ch) + +args = get_args() +config = args + +torch.manual_seed(args.seed) + +if not args.cuda: + args.gpu = -1 +if torch.cuda.is_available() and args.cuda: + logger.info("Note: You are using GPU for training") + torch.cuda.set_device(args.gpu) + torch.cuda.manual_seed(args.seed) +if torch.cuda.is_available() and not args.cuda: + logger.info("Warning: You have Cuda but do not use it. You are using CPU for training") +np.random.seed(args.seed) +random.seed(args.seed) + +QID = data.Field(sequential=False) +QUESTION = data.Field(batch_first=True) +ANSWER = data.Field(batch_first=True) +LABEL = data.Field(sequential=False) +EXTERNAL = data.Field(sequential=False, tensor_type=torch.FloatTensor, batch_first=True, use_vocab=False, + preprocessing=data.Pipeline(lambda x: x.split()), + postprocessing=data.Pipeline(lambda x, train: [float(y) for y in x])) +train, dev, test = TrecDataset.splits(QID, QUESTION, ANSWER, EXTERNAL, LABEL) + +QID.build_vocab(train, dev, test) +QUESTION.build_vocab(train, dev, test) +ANSWER.build_vocab(train, dev, test) +LABEL.build_vocab(train, dev, test) + +train_iter = data.Iterator(train, batch_size=args.batch_size, device=args.gpu, train=True, repeat=False, + sort=False, shuffle=True) +dev_iter = data.Iterator(dev, batch_size=args.batch_size, device=args.gpu, train=False, repeat=False, + sort=False, shuffle=False) +test_iter = data.Iterator(test, batch_size=args.batch_size, device=args.gpu, train=False, repeat=False, + sort=False, shuffle=False) + +config.target_class = len(LABEL.vocab) +config.questions_num = len(QUESTION.vocab) +config.answers_num = len(ANSWER.vocab) +print("Label dict:", LABEL.vocab.itos) + +if args.cuda: + model = torch.load(args.trained_model, map_location=lambda storage, location: storage.cuda(args.gpu)) +else: + model = torch.load(args.trained_model, map_location=lambda storage,location: storage) + +index2label = np.array(LABEL.vocab.itos) +index2qid = np.array(QID.vocab.itos) + +def predict(test_mode, dataset_iter): + model.eval() + dataset_iter.init_epoch() + + instance = [] + for dev_batch_idx, dev_batch in enumerate(dataset_iter): + qid_array = index2qid[np.transpose(dev_batch.qid.cpu().data.numpy())] + true_label_array = index2label[np.transpose(dev_batch.label.cpu().data.numpy())] + + scores = model(dev_batch) + + index_label = np.transpose(torch.max(scores, 1)[1].view(dev_batch.label.size()).cpu().data.numpy()) + label_array = index2label[index_label] + score_array = scores[:, 2].cpu().data.numpy() + # print and write the result + for i in range(dev_batch.batch_size): + this_qid, predicted_label, score, gold_label = qid_array[i], label_array[i], score_array[i], \ + true_label_array[i] + instance.append((this_qid, predicted_label, score, gold_label)) + + dev_map, dev_mrr = evaluate(instance, test_mode, config.mode) + print(dev_map, dev_mrr) + +# Run the model on the dev set +predict('dev', dataset_iter=dev_iter) + +# Run the model on the test set +predict('test', dataset_iter=test_iter) diff --git a/sm_modified_cnn/model.py b/sm_modified_cnn/model.py new file mode 100644 index 0000000..ecde607 --- /dev/null +++ b/sm_modified_cnn/model.py @@ -0,0 +1,83 @@ +import torch +import torch.nn as nn + +import torch.nn.functional as F + +class SmPlusPlus(nn.Module): + def __init__(self, config): + super(SmPlusPlus, self).__init__() + output_channel = config.output_channel + questions_num = config.questions_num + answers_num = config.answers_num + words_dim = config.words_dim + filter_width = config.filter_width + self.mode = config.mode + + n_classes = config.target_class + ext_feats_size = 4 + + if self.mode == 'multichannel': + input_channel = 2 + else: + input_channel = 1 + + self.question_embed = nn.Embedding(questions_num, words_dim) + self.answer_embed = nn.Embedding(answers_num, words_dim) + self.static_question_embed = nn.Embedding(questions_num, words_dim) + self.nonstatic_question_embed = nn.Embedding(questions_num, words_dim) + self.static_answer_embed = nn.Embedding(answers_num, words_dim) + self.nonstatic_answer_embed = nn.Embedding(answers_num, words_dim) + self.static_question_embed.weight.requires_grad = False + self.static_answer_embed.weight.requires_grad = False + + self.conv_q = nn.Conv2d(input_channel, output_channel, (filter_width, words_dim), padding=(filter_width - 1, 0)) + self.conv_a = nn.Conv2d(input_channel, output_channel, (filter_width, words_dim), padding=(filter_width - 1, 0)) + + self.dropout = nn.Dropout(config.dropout) + n_hidden = 2 * output_channel + ext_feats_size + + self.combined_feature_vector = nn.Linear(n_hidden, n_hidden) + self.hidden = nn.Linear(n_hidden, n_classes) + + def forward(self, x): + x_question = x.question + x_answer = x.answer + x_ext = x.ext_feat + + if self.mode == 'rand': + question = self.question_embed(x_question).unsqueeze(1) + answer = self.answer_embed(x_answer).unsqueeze(1) # (batch, sent_len, embed_dim) + x = [F.tanh(self.conv_q(question)).squeeze(3), F.tanh(self.conv_a(answer)).squeeze(3)] + x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] # max-over-time pooling + # actual SM model mode (Severyn & Moschitti, 2015) + elif self.mode == 'static': + question = self.static_question_embed(x_question).unsqueeze(1) + answer = self.static_answer_embed(x_answer).unsqueeze(1) # (batch, sent_len, embed_dim) + x = [F.tanh(self.conv_q(question)).squeeze(3), F.tanh(self.conv_a(answer)).squeeze(3)] + x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] # max-over-time pooling + elif self.mode == 'non-static': + question = self.nonstatic_question_embed(x_question).unsqueeze(1) + answer = self.nonstatic_answer_embed(x_answer).unsqueeze(1) # (batch, sent_len, embed_dim) + x = [F.tanh(self.conv_q(question)).squeeze(3), F.tanh(self.conv_a(answer)).squeeze(3)] + x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] # max-over-time pooling + elif self.mode == 'multichannel': + question_static = self.static_question_embed(x_question) + answer_static = self.static_answer_embed(x_answer) + question_nonstatic = self.nonstatic_question_embed(x_question) + answer_nonstatic = self.nonstatic_answer_embed(x_answer) + question = torch.stack([question_static, question_nonstatic], dim=1) + answer = torch.stack([answer_static, answer_nonstatic], dim=1) + x = [F.tanh(self.conv_q(question)).squeeze(3), F.tanh(self.conv_a(answer)).squeeze(3)] + x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] # max-over-time pooling + else: + print("Unsupported Mode") + exit() + + # append external features and feed to fc + x.append(x_ext) + x = torch.cat(x, 1) + + x = F.tanh(self.combined_feature_vector(x)) + x = self.dropout(x) + x = self.hidden(x) + return x \ No newline at end of file diff --git a/sm_modified_cnn/requirements.txt b/sm_modified_cnn/requirements.txt new file mode 100644 index 0000000..00d0293 --- /dev/null +++ b/sm_modified_cnn/requirements.txt @@ -0,0 +1,3 @@ +nltk==3.2.1 +numpy==1.11.3 +pytorch==0.1.12 diff --git a/sm_modified_cnn/train.py b/sm_modified_cnn/train.py new file mode 100644 index 0000000..367b159 --- /dev/null +++ b/sm_modified_cnn/train.py @@ -0,0 +1,214 @@ +import time +import os +import numpy as np +import random + +import logging +import torch +import torch.nn as nn +from torchtext import data + +from args import get_args +from model import SmPlusPlus +from trec_dataset import TrecDataset +from evaluate import evaluate + +args = get_args() +config = args + +torch.manual_seed(args.seed) + +def set_vectors(field, vector_path): + if os.path.isfile(vector_path): + stoi, vectors, dim = torch.load(vector_path) + field.vocab.vectors = torch.Tensor(len(field.vocab), dim) + + for i, token in enumerate(field.vocab.itos): + wv_index = stoi.get(token, None) + if wv_index is not None: + field.vocab.vectors[i] = vectors[wv_index] + else: + # initialize with U(-0.25, 0.25) vectors + field.vocab.vectors[i] = torch.FloatTensor(dim).uniform_(-0.25, 0.25) + else: + print("Error: Need word embedding pt file") + print("Error: Need word embedding pt file") + exit(1) + return field + + +def regularize_loss(model, loss): + flattened_params = [] + reg = args.weight_decay + + for p in model.parameters(): + f = p.data.clone() + flattened_params.append(f.view(-1)) + + fp = torch.cat(flattened_params) + loss = loss + 0.5 * reg * fp.norm() * fp.norm() + return loss + +# Set default configuration in : args.py +args = get_args() +config = args + +# Set random seed for reproducibility +torch.manual_seed(args.seed) +if not args.cuda: + args.gpu = -1 +if torch.cuda.is_available() and args.cuda: + print("Note: You are using GPU for training") + torch.cuda.set_device(args.gpu) + torch.cuda.manual_seed(args.seed) +if torch.cuda.is_available() and not args.cuda: + print("You have Cuda but you're using CPU for training.") +np.random.seed(args.seed) +random.seed(args.seed) + +QID = data.Field(sequential=False) +QUESTION = data.Field(batch_first=True) +ANSWER = data.Field(batch_first=True) +LABEL = data.Field(sequential=False) +EXTERNAL = data.Field(sequential=False, tensor_type=torch.FloatTensor, batch_first=True, use_vocab=False, + preprocessing=data.Pipeline(lambda x: x.split()), + postprocessing=data.Pipeline(lambda x, train: [float(y) for y in x])) +train, dev, test = TrecDataset.splits(QID, QUESTION, ANSWER, EXTERNAL, LABEL) + +QID.build_vocab(train, dev, test) +QUESTION.build_vocab(train, dev, test) +ANSWER.build_vocab(train, dev, test) +LABEL.build_vocab(train, dev, test) + + +QUESTION = set_vectors(QUESTION, args.vector_cache) +ANSWER = set_vectors(ANSWER, args.vector_cache) + +train_iter = data.Iterator(train, batch_size=args.batch_size, device=args.gpu, train=True, repeat=False, + sort=False, shuffle=True) +dev_iter = data.Iterator(dev, batch_size=args.batch_size, device=args.gpu, train=False, repeat=False, + sort=False, shuffle=False) +test_iter = data.Iterator(test, batch_size=args.batch_size, device=args.gpu, train=False, repeat=False, + sort=False, shuffle=False) + +config.target_class = len(LABEL.vocab) +config.questions_num = len(QUESTION.vocab) +config.answers_num = len(ANSWER.vocab) + +print("Dataset {} Mode {}".format(args.dataset, args.mode)) +print("VOCAB num", len(QUESTION.vocab)) +print("LABEL.target_class:", len(LABEL.vocab)) +print("LABELS:", LABEL.vocab.itos) +print("Train instance", len(train)) +print("Dev instance", len(dev)) +print("Test instance", len(test)) + +if args.resume_snapshot: + if args.cuda: + model = torch.load(args.resume_snapshot, map_location=lambda storage, location: storage.cuda(args.gpu)) + else: + model = torch.load(args.resume_snapshot, map_location=lambda storage, location: storage) +else: + model = SmPlusPlus(config) + model.static_question_embed.weight.data.copy_(QUESTION.vocab.vectors) + model.nonstatic_question_embed.weight.data.copy_(QUESTION.vocab.vectors) + model.static_answer_embed.weight.data.copy_(ANSWER.vocab.vectors) + model.nonstatic_answer_embed.weight.data.copy_(ANSWER.vocab.vectors) + + if args.cuda: + model.cuda() + print("Shift model to GPU") + + +parameter = filter(lambda p: p.requires_grad, model.parameters()) + +# the SM model originally follows SGD but Adadelta is used here +optimizer = torch.optim.Adadelta(parameter, lr=args.lr, weight_decay=args.weight_decay) +criterion = nn.CrossEntropyLoss() +early_stop = False +best_dev_map = 0 +iterations = 0 +iters_not_improved = 0 +epoch = 0 +start = time.time() +header = ' Time Epoch Iteration Progress (%Epoch) Loss Dev/Loss Accuracy Dev/Accuracy' +dev_log_template = ' '.join('{:>6.0f},{:>5.0f},{:>9.0f},{:>5.0f}/{:<5.0f} {:>7.0f}%,{:>8.6f},{:8.6f},{:12.4f},{:12.4f}'.split(',')) +log_template = ' '.join('{:>6.0f},{:>5.0f},{:>9.0f},{:>5.0f}/{:<5.0f} {:>7.0f}%,{:>8.6f},{},{:12.4f},{}'.split(',')) +os.makedirs(args.save_path, exist_ok=True) +os.makedirs(os.path.join(args.save_path, args.dataset), exist_ok=True) +print(header) + +index2label = np.array(LABEL.vocab.itos) +index2qid = np.array(QID.vocab.itos) +index2question = np.array(ANSWER.vocab.itos) + +while True: + if early_stop: + print("Early Stopping. Epoch: {}, Best Dev Acc: {}".format(epoch, best_dev_map)) + break + epoch += 1 + train_iter.init_epoch() + n_correct, n_total = 0, 0 + + for batch_idx, batch in enumerate(train_iter): + iterations += 1 + model.train(); optimizer.zero_grad() + scores = model(batch) + n_correct += (torch.max(scores, 1)[1].view(batch.label.size()).data == batch.label.data).sum() + n_total += batch.batch_size + train_acc = 100. * n_correct / n_total + + loss = criterion(scores, batch.label) + loss = regularize_loss(model, loss) + loss.backward() + optimizer.step() + + # Evaluate performance on validation set + if iterations % args.dev_every == 1: + # switch model into evaluation mode + model.eval() + dev_iter.init_epoch() + n_dev_correct = 0 + dev_losses = [] + instance = [] + for dev_batch_idx, dev_batch in enumerate(dev_iter): + qid_array = index2qid[np.transpose(dev_batch.qid.cpu().data.numpy())] + true_label_array = index2label[np.transpose(dev_batch.label.cpu().data.numpy())] + + scores = model(dev_batch) + n_dev_correct += (torch.max(scores, 1)[1].view(dev_batch.label.size()).data == dev_batch.label.data).sum() + dev_loss = criterion(scores, dev_batch.label) + dev_losses.append(dev_loss.data[0]) + index_label = np.transpose(torch.max(scores, 1)[1].view(dev_batch.label.size()).cpu().data.numpy()) + label_array = index2label[index_label] + # get the relevance scores + score_array = scores[:, 2].cpu().data.numpy() + for i in range(dev_batch.batch_size): + this_qid, predicted_label, score, gold_label = qid_array[i], label_array[i], score_array[i], true_label_array[i] + instance.append((this_qid, predicted_label, score, gold_label)) + + + dev_map, dev_mrr = evaluate(instance, 'valid', config.mode) + print(dev_log_template.format(time.time() - start, + epoch, iterations, 1 + batch_idx, len(train_iter), + 100. * (1 + batch_idx) / len(train_iter), loss.data[0], + sum(dev_losses) / len(dev_losses), train_acc, dev_map)) + + # Update validation results + if dev_map > best_dev_map: + iters_not_improved = 0 + best_dev_map = dev_map + snapshot_path = os.path.join(args.save_path, args.dataset, args.mode+'_best_model.pt') + torch.save(model, snapshot_path) + else: + iters_not_improved += 1 + if iters_not_improved >= args.patience: + early_stop = True + break + + if iterations % args.log_every == 1: + # print progress message + print(log_template.format(time.time() - start, + epoch, iterations, 1 + batch_idx, len(train_iter), + 100. * (1 + batch_idx) / len(train_iter), loss.data[0], ' ' * 8, + n_correct / n_total * 100, ' ' * 12)) diff --git a/sm_modified_cnn/trec_dataset.py b/sm_modified_cnn/trec_dataset.py new file mode 100644 index 0000000..3621c49 --- /dev/null +++ b/sm_modified_cnn/trec_dataset.py @@ -0,0 +1,16 @@ +from torchtext import data +import os + +class TrecDataset(data.TabularDataset): + dirname = 'data' + @classmethod + + def splits(cls, question_id, question_field, answer_field, external_field, label_field, + train='train.tsv', validation='dev.tsv', test='test.tsv'): + path = './data' + prefix_name = 'trecqa.' + return super(TrecDataset, cls).splits( + os.path.join(path, prefix_name), train, validation, test, + format='TSV', fields=[('qid', question_id), ('label', label_field), ('question', question_field), + ('answer', answer_field), ('ext_feat', external_field)] + ) diff --git a/sm_modified_cnn/utils.py b/sm_modified_cnn/utils.py new file mode 100644 index 0000000..d53753d --- /dev/null +++ b/sm_modified_cnn/utils.py @@ -0,0 +1,38 @@ +from tqdm import tqdm +import array +import torch +import numpy as np + +from argparse import ArgumentParser + + +def convert(fname, vocab): + save_file = '{}.pt'.format(fname) + stoi, vectors, dim = [], array.array('d'), None + + # TODO: fix by reading the .dimensions file + vocab_size, dim = 2470719, 50 + W = np.memmap(fname, dtype=np.double, shape=(vocab_size, dim)) + + + print("Loading vectors from {}".format(fname)) + vectors = [] + for line in tqdm(W, total=len(W)): + entry = line + vectors.extend(entry) + + vectors = torch.Tensor(vectors).view(-1, dim) + + with open(vocab) as f: + stoi = {word.strip():i for i, word in enumerate(f)} + + print('saving vectors to', save_file) + torch.save((stoi, vectors, dim), save_file) + +if __name__ == '__main__': + parser = ArgumentParser(description='create word embedding') + parser.add_argument('--input', type=str, required=True) + parser.add_argument('--vocab', type=str, required=True) + + args = parser.parse_args() + convert(args.input, args.vocab)