package edu.stanford.nlp.sempre.tables;
import java.text.*;
import java.util.*;
import java.util.regex.*;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import edu.stanford.nlp.sempre.*;
import fig.basic.*;
/**
* Utilities for string normalization.
*
* @author ppasupat
*/
public final class StringNormalizationUtils {
public static class Options {
@Option(gloss = "Verbosity") public int verbose = 0;
@Option(gloss = "Use language analyzer")
public boolean useLanguageAnalyzer = true;
@Option(gloss = "NUMBER does not have to be at the beginning of the string")
public boolean numberCanStartAnywhere = false;
@Option(gloss = "NUM2 does not have to follow the pattern NUMBER DASH NUM2")
public boolean num2CanStartAnywhere = false;
}
public static Options opts = new Options();
private StringNormalizationUtils() { } // Should not be instantiated.
/**
* Analyze the content of the cells in the same column, and then generate possible normalizations.
* Modify the property map in each cell.
*
* TODO(ice): Take the homogeneity of the cells into account.
*/
public static void analyzeColumn(TableColumn column, Map<String, String> originalStringToPartId) {
// Check if any cell contains a comma-separated list
int numLists = 0;
for (TableCell cell : column.children) {
String[] splitted = COMMA.split(cell.properties.originalString);
if (splitted.length > 1) numLists++;
}
for (TableCell cell : column.children) {
if (!cell.properties.metadata.isEmpty()) continue; // Already analyzed.
analyzeString(cell.properties.originalString, cell.properties.metadata,
originalStringToPartId, numLists > 0);
}
}
// ============================================================
// Cell normalization
// ============================================================
public static final Pattern DASH = Pattern.compile("\\s*[-‐‑⁃‒–—―/,:;]\\s*");
public static final Pattern COMMA = Pattern.compile("\\s*(,\\s|\\n|/)\\s*");
public static final Pattern SPACE = Pattern.compile("\\s+");
public static void analyzeString(String o, Multimap<Value, Value> metadata,
Map<String, String> originalStringToPartId, boolean alwaysGenerateParts) {
metadata.clear();
Value value;
LanguageAnalyzer analyzer = LanguageAnalyzer.getSingleton();
LanguageInfo languageInfo = analyzer.analyze(o);
// ===== Number: Also handle "2,000 ft." --> (number 2000) =====
value = parseNumberLenient(o);
if (value == null && opts.useLanguageAnalyzer)
value = parseNumberWithLanguageAnalyzer(languageInfo);
if (value != null) metadata.put(TableTypeSystem.CELL_NUMBER_VALUE, value);
// ===== Date and Time =====
value = parseDate(o);
if (value == null && opts.useLanguageAnalyzer)
value = parseDateWithLanguageAnalyzer(languageInfo);
if (value != null) metadata.put(TableTypeSystem.CELL_DATE_VALUE, value);
// ===== First and Second: "2-1" --> first = (number 2), second = (number 1) =====
if (opts.num2CanStartAnywhere) {
value = parseNum2Lenient(o);
if (value != null)
metadata.put(TableTypeSystem.CELL_NUM2_VALUE, value);
} else {
String[] splitted = DASH.split(o);
if (splitted.length != 2) splitted = SPACE.split(o);
if (splitted.length == 2) {
NumberValue first = parseNumberStrict(splitted[0]), second = parseNumberStrict(splitted[1]);
if (first != null && second != null) {
metadata.put(TableTypeSystem.CELL_NUM2_VALUE, second);
}
}
}
// ===== List: "apple, banana, carrot" --> fb:part.apple, etc. =====
String[] splitted = COMMA.split(o);
if (splitted.length > 1 || alwaysGenerateParts) {
for (String x : splitted) {
String id = TableTypeSystem.getOrCreateName(x, originalStringToPartId,
(String canonicalName) -> TableTypeSystem.getPartName(canonicalName));
metadata.put(TableTypeSystem.CELL_PART_VALUE, new NameValue(id, x));
}
}
}
// ============================================================
// Type Conversion
// ============================================================
public static final NumberFormat numberFormat = NumberFormat.getInstance(Locale.US);
/**
* Convert string to number.
* Partial match is allowed: "9,000 cakes" --> 9000
*/
public static NumberValue parseNumberLenient(String s) {
try {
if (opts.numberCanStartAnywhere)
s = s.replaceAll("^[^0-9.]*", "");
Number parsed = numberFormat.parse(s.replace(" ", ""));
return new NumberValue(parsed.doubleValue());
} catch (ParseException e) {
return null;
}
}
/**
* Get the second number
* Partial match is allowed: "9,000 cakes from 120 bakeries" --> 120
*/
public static NumberValue parseNum2Lenient(String s) {
s = s.replace(" ", "");
if (opts.numberCanStartAnywhere)
s = s.replaceAll("^[^0-9.]*", "");
ParsePosition parsePosition = new ParsePosition(0);
Number parsed = numberFormat.parse(s, parsePosition);
if (parsed == null) return null;
s = s.substring(parsePosition.getIndex());
s = s.replaceAll("^[^0-9.]*", "");
parsePosition.setIndex(0);
parsed = numberFormat.parse(s, parsePosition);
if (parsed == null) return null;
return new NumberValue(parsed.doubleValue());
}
/**
* Convert string to number.
* Partial match is not allowed: "9,000 cakes" --> null
*/
public static NumberValue parseNumberStrict(String s) {
ParsePosition pos = new ParsePosition(0);
Number parsed = numberFormat.parse(s, pos);
if (parsed == null || s.length() != pos.getIndex()) return null;
return new NumberValue(parsed.doubleValue());
}
/**
* Convert string to number + unit.
* Must exactly match the pattern "number unit" (e.g., "9,000 cakes")
*/
public static NumberValue parseNumberWithUnitStrict(String s) {
String[] tokens = s.split(" ");
if (tokens.length != 2) return null;
ParsePosition pos = new ParsePosition(0);
Number parsed = numberFormat.parse(tokens[0], pos);
if (parsed == null || tokens[0].length() != pos.getIndex()) return null;
return new NumberValue(parsed.doubleValue(), tokens[1]);
}
public static NumberValue parseNumberWithLanguageAnalyzer(LanguageInfo languageInfo) {
if (languageInfo.numTokens() == 0) return null;
String nerSpan;
nerSpan = languageInfo.getNormalizedNerSpan("NUMBER", 0, languageInfo.numTokens());
if (nerSpan != null) {
try {
return new NumberValue(Double.parseDouble(nerSpan));
} catch (NumberFormatException e) { }
}
nerSpan = languageInfo.getNormalizedNerSpan("ORDINAL", 0, languageInfo.numTokens());
if (nerSpan != null) {
try {
return new NumberValue(Double.parseDouble(nerSpan));
} catch (NumberFormatException e) { }
}
nerSpan = languageInfo.getNormalizedNerSpan("PERCENT", 0, languageInfo.numTokens());
if (nerSpan != null) {
try {
return new NumberValue(Double.parseDouble(nerSpan.substring(1)));
} catch (NumberFormatException e) { }
}
nerSpan = languageInfo.getNormalizedNerSpan("MONEY", 0, languageInfo.numTokens());
if (nerSpan != null) {
try {
return new NumberValue(Double.parseDouble(nerSpan.substring(1)));
} catch (NumberFormatException e) { }
}
return null;
}
public static final DateTimeFormatter dateFormat = DateTimeFormat.forPattern("MMM d, yyyy");
/**
* Convert string to DateValue.
*/
public static DateValue parseDate(String s) {
try {
DateTime date = dateFormat.parseDateTime(s);
return new DateValue(date.getYear(), date.getMonthOfYear(), date.getDayOfMonth());
} catch (IllegalArgumentException e) {
return null;
}
}
public static final Pattern suTimeDateFormat = Pattern.compile("([0-9X]{4})(?:-([0-9X]{2}))?(?:-([0-9X]{2}))?");
public static DateValue parseDateWithLanguageAnalyzer(LanguageInfo languageInfo) {
if (languageInfo.numTokens() == 0) return null;
String nerSpan = languageInfo.getNormalizedNerSpan("DATE", 0, languageInfo.numTokens());
if (opts.verbose >= 2)
LogInfo.logs("%s %s %s %s", languageInfo.tokens, languageInfo.nerTags, languageInfo.nerValues, nerSpan);
if (nerSpan == null) return null;
Matcher matcher = suTimeDateFormat.matcher(nerSpan);
if (!matcher.matches()) return null;
String yS = matcher.group(1), mS = matcher.group(2), dS = matcher.group(3);
int y = -1, m = -1, d = -1;
if (!(yS == null || yS.isEmpty() || yS.contains("X"))) y = Integer.parseInt(yS);
if (!(mS == null || mS.isEmpty() || mS.contains("X"))) m = Integer.parseInt(mS);
if (!(dS == null || dS.isEmpty() || dS.contains("X"))) d = Integer.parseInt(dS);
if (y == -1 && m == -1 && d == -1) return null;
return new DateValue(y, m, d);
}
// ============================================================
// Generic String normalization
// ============================================================
/**
* newline (=> `\n`), backslash (`\` => `\\`), and pipe (`|` => `\p`)
*/
public static String escapeTSV(String x) {
return x.replace("\\", "\\\\").replace("\n", "\\n").replace("|", "\\p").replaceAll("\\s", " ").trim();
}
public static String unescapeTSV(String x) {
return x.replace("\\n", "\n").replace("\\p", "|").replace("\\\\", "\\");
}
/**
* Collapse multiple spaces into one.
*/
public static String whitespaceNormalize(String x) {
return x.replaceAll("\\s", " ").trim();
}
/**
* Remove ALL spaces and non-alphanumeric characters, then convert to lower case.
* Used for fuzzy matching.
*/
public static String collapseNormalize(String x) {
return Normalizer.normalize(x, Normalizer.Form.NFD).replaceAll("[^A-Za-z0-9]", "").toLowerCase();
}
/**
* String to number
*/
public static NumberValue toNumberValue(String description) {
if (description == null) return null;
try {
Number result = numberFormat.parse(description);
return new NumberValue(result.doubleValue());
} catch (ParseException e) {
return null;
}
}
public static NumberValue toNumberValue(Value value) {
if (value instanceof NumberValue) return (NumberValue) value;
if (value instanceof DateValue) {
DateValue date = (DateValue) value;
if (date.month == -1 && date.day == -1)
return new NumberValue(date.year);
}
if (value instanceof NameValue) return toNumberValue(((NameValue) value).description);
if (value instanceof DescriptionValue) return toNumberValue(((DescriptionValue) value).value);
return null;
}
/**
* Character normalization.
*/
public static String characterNormalize(String string) {
// Remove diacritics // (Sorry European people)
string = Normalizer.normalize(string, Normalizer.Form.NFD).replaceAll("[\u0300-\u036F]", "");
// Special symbols
string = string
.replaceAll("‚", ",")
.replaceAll("„", ",,")
.replaceAll("[·・]", ".")
.replaceAll("…", "...")
.replaceAll("ˆ", "^")
.replaceAll("˜", "~")
.replaceAll("‹", "<")
.replaceAll("›", ">")
.replaceAll("[‘’´`]", "'")
.replaceAll("[“”«»]", "\"")
.replaceAll("[•†‡]", "")
.replaceAll("[‐‑–—]", "-")
.replaceAll("[\\u2E00-\\uFFFF]", ""); // (Sorry Chinese people)
return string.replaceAll("\\s+", " ").trim();
}
/**
* Simple normalization. (Include whitespace normalization)
*/
public static String simpleNormalize(String string) {
string = characterNormalize(string);
// Citation
string = string.replaceAll("\\[(nb ?)?\\d+\\]", "");
string = string.replaceAll("\\*+$", "");
// Year in parentheses
string = string.replaceAll("\\(\\d* ?-? ?\\d*\\)", "");
// Outside Quote
string = string.replaceAll("^\"(.*)\"$", "$1");
// Numbering
if (!string.matches("^[0-9.]+$"))
string = string.replaceAll("^\\d+\\.", "");
return string.replaceAll("\\s+", " ").trim();
}
/**
* More aggressive normalization. (Include simple and whitespace normalization)
*/
public static String aggressiveNormalize(String string) {
// Dashed / Parenthesized information
string = simpleNormalize(string);
string = string.replaceAll("\\[[^\\]]*\\]", "");
string = string.replaceAll("[\\u007F-\\uFFFF]", "");
string = string.trim().replaceAll(" - .*$", "");
string = string.trim().replaceAll("\\([^)]*\\)$", "");
return string.replaceAll("\\s+", " ").trim();
}
// ============================================================
// Test
// ============================================================
private static void unitTest(String o) {
Multimap<Value, Value> metadata = ArrayListMultimap.create();
analyzeString(o, metadata, new HashMap<>(), false);
LogInfo.logs("%s %s", o, metadata);
}
public static void main(String[] args) {
LanguageAnalyzer.opts.languageAnalyzer = "corenlp.CoreNLPAnalyzer";
opts.verbose = 2;
opts.numberCanStartAnywhere = true;
opts.num2CanStartAnywhere = true;
unitTest("2");
unitTest("twenty three");
unitTest("apple, banana, banana, BANANA");
unitTest("apple\nbanana\norange");
unitTest("21st");
unitTest("2001st");
unitTest("2,000,000 ft.");
unitTest("2,000,000.3579");
unitTest("1/2");
unitTest("1-2");
unitTest("50%");
unitTest("$30");
unitTest("1 104");
unitTest("United States of America (USA)");
unitTest("320 bhp diesel, 10 knots (19 km/h)");
unitTest("January 3, 1993");
unitTest("July 2008");
// normalized-annotated-200.examples
unitTest("19 September 1984"); // Ex 90
unitTest("July 9"); // Ex 139, 155
unitTest("March 1983"); // Ex 167
// Other things handled by SUTime
unitTest("Friday");
unitTest("Every Friday");
unitTest("7:00");
unitTest("7pm");
unitTest("January 2, 7am");
unitTest("morning");
unitTest("1993-95");
unitTest("Jan 2-5");
unitTest("Jan 2 - 5");
unitTest("Jan 2 - Feb 5");
unitTest("from dawn to dusk");
unitTest("January 4 - February 10");
unitTest("July 500");
unitTest("July 500 B.C.");
unitTest("1800s");
unitTest("19th century");
}
}