diff --git a/data/ES-ranking-targets.yaml b/data/ES-ranking-targets.yaml new file mode 100644 index 00000000..ee01dbdf --- /dev/null +++ b/data/ES-ranking-targets.yaml @@ -0,0 +1,353 @@ +--- +search: "A Reader's Guide to Hart Craine's White Buildings" +scope: "keyword" +metric: "precision" +metric_at: 3 +relevant: + - "b11785956" + - "pb998308253506421" +notes: + - "Misspelling of 'Crane' for 'Craine' " + - "@3 ensures we find them all. Fragile though" +reason: "initial target" +--- +search: "A Reader's Guide to Hart Craine's White Buildings" +scope: "keyword" +metric: "precision" +metric_at: 1 +relevant: + - "b11785956" +notes: + - "Misspelling of 'Crane' for 'Craine' " + - "Like previous but maybe better, ensures the NYPL record the top hit" +reason: "initial target" +--- +search: "candide" +scope: "keyword" +metric: "recall" +metric_at: 10 +relevant: + - "b10690141" + - "b10780034" + - "b14000114" + - "cb620952" + - "cb5834647" + - "cb10171120" + - "cb11607983" + - "b11785903" + - "b16593688" + - "b16600011" +notes: + - "Current results, but there might be better Candides" + - "Ensures similar terms ('candid', 'candidate') don't dominate" +reason: "initial target" +--- +search: "liberator" +scope: "keyword" +metric: "recall" +metric_at: 25 +relevant: + - "b12316212" + - "b13204619" +notes: + - "There is probably a fuller list" +reason: "initial target" +--- +search: "time magazine" +scope: "journal title" +metric: "recall" +metric_at: 50 +relevant: + - "b10682828" + - "b17394425" + - "b21462251" + - "b16542771" +notes: + - "Results aren't great because 'magazine' throws it off" + - "Something we want to track and improve" +reason: "initial target" +--- +search: "time" +scope: "journal title" +metric: "recall" +metric_at: 50 +relevant: + - "b10682828" + - "b17394425" + - "b21462251" + - "b16542771" +notes: + - "Should be better thab 'time magazine'" +reason: "initial target" +--- +search: "sassy" +scope: "keyword" +metric: "recall" +metric_at: 50 +relevant: + - "b11612329" +notes: + - "Should be better than 'sassy magazine'" +reason: "initial target" +--- +search: "letter from tombs prison" +scope: "title" +metric: "recall" +metric_at: 10 +relevant: + - "b11782546" + - "pb9938471653506421" + - "cb2080258" +notes: + - "Title is 'Letter from the Tombs' and the addition of 'prison' throws it off" +reason: "initial target" +--- +search: "letter from the tombs" +scope: "title" +metric: "precision" +metric_at: 1 +relevant: + - "b11782546" +notes: + - "Should be better than 'letter from tombs prison'" +reason: "initial target" +--- +search: "gold, leonard" +scope: "contributor" +metric: "precision" +metric_at: 2 +relevant: + - "b10002582" + - "b14650132" +reason: "initial target" +--- +search: "alexander, victor" +scope: "contributor" +metric: "recall" +metric_at: 50 +relevant: + - "b16476737" + - "b13998639" + - "hb990031177450203941" + - "cb2235121" +reason: "initial target" +--- +search: "journal of jewish studies" +scope: "journal title" +metric: "precision" +metric_at: 1 +relevant: + - "b11151367" +reason: "initial target" +--- +search: "East and West" +scope: "journal title" +metric: "recall" +metric_at: 50 +relevant: + - "b11378929" + - "b10988853" + - "b21433970" + - "b15276563" + - "b15087757" + - "b15088556" +reason: "initial target" +--- +search: "a man's a man" +scope: "title" +metric: "precision" +metric_at: 3 +relevant: + - "b12411226" + - "b16761751" + - "b20009609" +reason: "initial target" +--- +search: "beginnings of jewishness" +scope: "title" +metric: "precision" +metric_at: 2 +relevant: + - "b15264124" + - "cb2167369" +reason: "initial target" +notes: + - "We would like to see the NYPL copy first" +--- +search: "a mom's life" +scope: "title" +metric: "recall" +metric_at: 4 +relevant: + - "b16556599" + - "b19927929" + - "b16542290" + - "b16649091" +reason: "initial target" +--- +search: "new york times" +scope: "keyword" +metric: "recall" +metric_at: 50 +relevant: + - "pb626684" + - "b12639440" + - "b21835201" + - "b12403270" + - "b21941401" + - "cb524908" + - "b21148115" + - "b21168678" + - "b21148447" + - "b11801637" + - "b21148114" +reason: "initial target" +--- +search: "wired" +scope: "keyword" +metric: "recall" +metric_at: 10 +relevant: + - "b22965079" + - "b22214385" + - "b21937839" + - "b17218096" + - "b22160964" + - "pb999474273506421" + - "b11686313" + - "b11514515" + - "b21884998" +reason: "initial target" +--- +search: "\u0022In memory of memory\u0022" +scope: "title" +metric: "precision" +metric_at: 1 +relevant: + - "b22405724" +reason: "initial target" +notes: + - "Search with quotes" +--- +search: "\u0022in cold blood\u0022" +scope: "title" +metric: "precision" +metric_at: 2 +relevant: + - "b11064010" + - "b21582147" +reason: "initial target" +notes: + - "Search with quotes" +--- +search: "the shroud of christ" +scope: "title" +metric: "precision" +metric_at: 4 +relevant: + - "b11095468" + - "cb4692334" + - "pb1267391" + - "b23193131" +reason: "initial target" +notes: + - "Search with quotes" +--- +search: "kosovsky otsar ha-leshon" +scope: "keyword" +metric: "recall" +metric_at: 10 +relevant: + - "b14258861" + - "b14258864" + - "b11904261" + - "b14197265" +reason: "initial target" +notes: + - "Prioritize onsite" +--- +search: "encyclopedia of jewish diaspora" +scope: "keyword" +metric: "precision" +metric_at: 1 +relevant: + - "b22180471" +reason: "initial target" +notes: + - "Prioritize onsite" +--- +search: "33433135877482" +scope: "keyword" +metric: "precision" +metric_at: 1 +relevant: + - "b22935532" +reason: "initial target" +notes: + - "Exact barcode" +--- +search: "Vinipeg" +scope: "keyword" +metric: "precision" +metric_at: 4 +relevant: + - "b13959162" + - "b16422751" + - "b11448009" + - "cb2859634" +reason: "initial target" +--- +search: "JFD 16-5143" +scope: "keyword" +metric: "precision" +metric_at: 1 +relevant: + - "b21099627" +reason: "initial target" +--- +search: "\u0022center of life\u0022" +scope: "keyword" +metric: "precision" +metric_at: 2 +relevant: + - "cb4793271" + - "cb270248" +reason: "initial target" +--- +search: "Peribáñez y el comendador de Ocaña" +scope: "keyword" +metric: "precision" +metric_at: 6 +relevant: + - "b15578492" + - "b10853092" + - "b11361733" + - "b13439547" + - "b11041128" + - "b13266844" +reason: "initial target" +--- +search: "the power broker" +scope: "keyword" +metric: "recall" +metric_at: 5 +relevant: + - "b10311633" + - "b21586756" + - "cb202556" +notes: + - "Note to self: maybe the is a good example for using Recall. There are other books called 'The Power Broker' and we want to be sure that all the Caro books (3) are at least in the top 5" +reason: "initial target" +--- +search: "\u0022the power broker\u0022" +scope: "keyword" +metric: "recall" +metric_at: 5 +relevant: + - "b10311633" + - "b21586756" + - "cb202556" +reason: "initial target" + + + diff --git a/package-lock.json b/package-lock.json index d837bee0..95ca6716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "chai-as-promised": "^7.1.1", "chalk": "^4.1.2", "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.1", "git-diff": "^2.0.6", "md5": "^2.2.1", "minimist": "^1.2.5", @@ -4934,6 +4935,11 @@ "version": "0.12.0", "license": "Apache-2.0" }, + "node_modules/@nypl/nypl-core-objects/node_modules/csv-stringify": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.6.5.tgz", + "integrity": "sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==" + }, "node_modules/@nypl/nypl-core-objects/node_modules/http-basic": { "version": "8.1.3", "license": "MIT", @@ -5037,6 +5043,11 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/@nypl/nypl-data-api-client/node_modules/csv-stringify": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.6.5.tgz", + "integrity": "sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==" + }, "node_modules/@nypl/nypl-data-api-client/node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -6986,13 +6997,20 @@ "license": "MIT" }, "node_modules/csv-stringify": { - "version": "5.6.5", - "license": "MIT" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.1.tgz", + "integrity": "sha512-+9lpZfwpLntpTIEpFbwQyWuW/hmI/eHuJZD1XzeZpfZTqkf1fyvBbBLXTJJMsBuuS11uTShMqPwzx4A6ffXgRQ==", + "dev": true }, "node_modules/csv/node_modules/csv-parse": { "version": "4.16.3", "license": "MIT" }, + "node_modules/csv/node_modules/csv-stringify": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.6.5.tgz", + "integrity": "sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==" + }, "node_modules/dashdash": { "version": "1.14.1", "dev": true, diff --git a/package.json b/package.json index aed09ffd..e83f91bf 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "@nypl/nypl-core-objects": "2.3.2", "@nypl/nypl-data-api-client": "^2.0.0", "@nypl/scsb-rest-client": "3.0.0", - "research-catalog-indexer": "git+https://github.com/NYPL/research-catalog-indexer.git", "dotenv": "^16.4.5", "express": "^4.18.3", + "research-catalog-indexer": "git+https://github.com/NYPL/research-catalog-indexer.git", "winston": "3.12.0" }, "devDependencies": { @@ -19,6 +19,7 @@ "chai-as-promised": "^7.1.1", "chalk": "^4.1.2", "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.1", "git-diff": "^2.0.6", "md5": "^2.2.1", "minimist": "^1.2.5", diff --git a/scripts/run-ranking-evaluation.js b/scripts/run-ranking-evaluation.js new file mode 100644 index 00000000..8341048b --- /dev/null +++ b/scripts/run-ranking-evaluation.js @@ -0,0 +1,212 @@ +/** +* This script runs a series of rank-eval calls against the configured index to +* produce a bunch of numbers from 0 to 1 that represent relevancy performance +# across a number of metics. We collect these numbers in a spreadsheet to track +* relevancy performance over time. +* +* Usage: +* nvm use; ENV=qa node scripts/run-ranking-evaluation.js; pbcopy < out.csv +* +* .. Then, paste the row into the next position in this spreadsheet: +* https://docs.google.com/spreadsheets/d/12uDDxi3bInneZwmUQwEiYYkHheNkqW0WT5jxWvix4BE/edit?pli=1&gid=1424571383#gid=1424571383 +* +* After pasting, copy col C from the previous row into the newly pasted row +* (because the forumla uses relative references). +* +*/ +const fs = require('fs') +const YAML = require('yaml') +const { fromIni } = require('@aws-sdk/credential-providers') +const { stringify } = require('csv-stringify/sync') + +const { setCredentials: setKmsCredentials } = require('../lib/kms-helper') +const { loadConfig } = require('../lib/load-config') + +// If you need to run this on a ES 5.3 index, you'll need to use branch +// `pre-es-work-snapshot` and set the following to false because the ES client +// needs to be v7: +const v8Client = true +let esClient +if (v8Client) { + esClient = require('../lib/elasticsearch/client').esClient +} else { + esClient = require('../lib/es-client').esClient +} + +// Pass in object so we get a reference to private methods like `buildElasticQuery` +const resourcesPriv = {} +require('../lib/resources')({}, resourcesPriv) + +const argv = require('minimist')(process.argv.slice(2), { + default: { + offset: 0, + limit: Infinity, + rows: null, + outputHeader: false, + input: './data/ES-ranking-targets.yaml', + profile: 'nypl-digital-dev', + envfile: './config/qa.env', + outfile: 'out.csv', + verbose: false + }, + string: ['rows'], + boolean: ['outputHeader', 'verbose'] +}) + +require('dotenv').config({ path: argv.envfile || '.env' }) + +// Use creds from local profile: +setKmsCredentials(fromIni({ profile: argv.profile })) + +/** +* Given a rank-eval target (JSONified representation of a row from +* ES-ranking-targets.yaml), returns the ES rank-eval call. +**/ +const rankEvaluationCall = (target) => { + const searchScope = { + keyword: 'all', + 'journal title': 'title' + }[target.scope] || target.scope + const query = resourcesPriv.buildElasticQuery({ q: target.search, search_scope: searchScope }) + if (target.scope === 'journal title') { + query.bool.filter = [{ term: { 'issuance.id': 'urn:biblevel:s' } }] + } + return { + requests: [ + { + id: 'report', + request: { + query + }, + ratings: target.relevant + .map((bnum, ind) => { + return { + _index: process.env.RESOURCES_INDEX, + _id: bnum, + // Higher "rating" indicates higher importance, but 'precision at + // k' doesn't care about order, so set them all to 1, the default + // relevant_rating_threshold: + rating: 1 + } + }) + } + ], + metric: { + [target.metric]: { + k: target.metric_at, + relevant_rating_threshold: 1 + } + } + } +} + +/** +* Given an array of rank-eval targets (JSONified representation of a row from +* ES-ranking-targets.yaml), resolves an array of objects that define: +* - response: The ES response for the rank-eval call +* - target: The original rank-eval target +* - query: The ES rank-eval query used +**/ +const runTargets = async (targets, index = 0, responses = []) => { + const target = targets[index] + + if (!target) return responses + + const query = rankEvaluationCall(target) + + const client = await esClient() + const translatedQuery = v8Client ? query : { body: query } + const payload = Object.assign({}, translatedQuery, { index: process.env.RESOURCES_INDEX }) + const response = await await client.rankEval(payload) + + responses.push({ + response, + target, + query + }) + return runTargets(targets, index + 1, responses) +} + +/** +* Main function. Based on argv options, runs app specified rank-eval queries and reports results. +**/ +const run = async () => { + await loadConfig() + + const content = fs.readFileSync(argv.input, 'utf8') + let targets = YAML.parseAllDocuments(content) + .map((t) => t.toJS()) + if (argv.rows) { + const rows = argv.rows + .split(',') + .map((i) => parseInt(i)) + if (argv.verbose) { + console.info(`Restricting to rows: ${rows}`) + } + targets = targets.filter((_, i) => rows.includes(i)) + } + + const responses = await runTargets(targets) + + if (argv.verbose) { + responses.forEach(({ target, query, response }) => { + console.info('_________________________________________________________________') + + const result = { + score: (response.body || response).details.report.metric_score + } + + console.info(`Result: of ${target.metric}@${target.metric_at} test of "${target.search}" ${target.scope} query: ${result.score}`) + }) + } + + const asCsv = [] + + // Optionally add header: + if (argv.outputHeader) { + asCsv.push( + ['Date', 'Commit', 'Changes'].concat( + targets.map((target, index) => { + return `${index}: ${target.metric}@${target.metric_at} test of "${target.search}" ${target.scope}` + }) + ) + ) + } + + const date = new Date().toISOString() + const commit = currentCommit() + asCsv.push( + [ + date, + commit, + // Need to manually copy the formula from another row because the + // relative references aren't respsected when pasting a whole row in: + '' + ].concat( + responses.map(({ response }) => { + return (response.body || response).details.report.metric_score + }) + ) + ) + + const rows = stringify(asCsv, { delimiter: '\t' }) + fs.writeFileSync(argv.outfile, rows) + + console.info(rows) + + if (argv.verbose) { + console.info(`Wrote results to ${argv.outfile}`) + } +} + +/** +* Get current git commit hash +**/ +const currentCommit = () => { + const execSync = require('child_process').execSync + const output = execSync('git rev-parse HEAD').toString() + .trim() + return output +} + +run()