File size: 4,893 Bytes
cda954d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f26f25
 
 
 
 
 
 
 
 
 
 
 
cda954d
5f26f25
cda954d
 
 
 
 
 
 
 
5f26f25
 
cda954d
 
 
5f26f25
 
 
 
 
cda954d
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""Turn labelled JSONL into training records for the Laya encoder.

A record is ONE text with ALL its questions. That is not a space-saving trick:
Laya answers several questions in a single forward pass, and RLCD trains on
episodes that carry more than one question.

Input format, one JSON object per line:

    {"id": "...", "text": "...", "topic": "politics", "severity": 3, "violent": false}

Question definitions (--questions), a JSON object:

    {
      "topic": {
        "type": "choice",
        "instructions": "Which area does this text belong to?",
        "criteria": {"politics": "government, elections, ...",
                     "business": "companies, markets, ..."}
      },
      "severity": {
        "type": "score",
        "instructions": "How severe is the described event?",
        "criteria": ["none", "minor", "notable", "serious", "extreme"]
      },
      "violent": {
        "type": "bool",
        "instructions": "Does the text describe physical violence?",
        "criteria": {"true": "yes, against people", "false": "no"}
      }
    }

Three question types, matching Laya's own:
  choice  unordered categories; the label is one of the criteria keys
  score   ordinal scale; the label is an integer index into the criteria list
  bool    yes/no; the label is a JSON boolean

A field missing from a line is simply skipped for that record -- questions do
not all have to be labelled on the same texts. That is how you mix a large,
cheaply labelled set for one question with a small, carefully labelled set for
another.
"""
import json

MAX_CHARS = 2800     # what fits comfortably into the encoder's window


def load_questions(path):
    """Read the question definitions and normalise them for the encoder."""
    raw = json.load(open(path))
    out = {}
    for name, d in raw.items():
        t = d["type"]
        if t == "choice":
            out[name] = {"t": "choice", "ins": d["instructions"],
                         "crit": d["criteria"], "keys": list(d["criteria"])}
        elif t == "score":
            out[name] = {"t": "score", "ins": d["instructions"], "crit": d["criteria"]}
        elif t == "bool":
            # the encoder calls this type "noul"
            c = d["criteria"]
            out[name] = {"t": "noul", "ins": d["instructions"],
                         "crit": {"true": c["true"], "false": c["false"]}}
        else:
            raise ValueError("unknown question type %r for %r" % (t, name))
    return out


def _question(defs, name, value):
    d = defs[name]
    if d["t"] == "choice":
        if value not in d["keys"]:
            return None
        y = d["keys"].index(value)
    elif d["t"] == "score":
        if not isinstance(value, int) or not (0 <= value < len(d["crit"])):
            return None
        y = value
    else:
        if value is None:
            return None
        y = 1 if value else 0
    return {"t": d["t"], "ins": d["ins"], "crit": d["crit"], "y": y, "name": name}


def records(path, defs, max_chars=MAX_CHARS, only=None, quiet=False):
    """Build encoder records from a JSONL file.

    Labels that do not fit their question definition are skipped -- an unknown
    category, or an ordinal value outside the scale. That is the right thing to
    do, but doing it SILENTLY is not: we lost 21 of 176 gold judgements that way
    once, because a five-level scale had been collapsed to three levels in the
    training data but not in the gold file. The evaluation still ran and still
    printed a number. It just was not the number we thought.

    So: count them, and say so.
    """
    out = []
    dropped = {}
    for line in open(path):
        x = json.loads(line)
        qs = []
        for name in (only or defs):
            if name in x:
                q = _question(defs, name, x[name])
                if q:
                    qs.append(q)
                else:
                    dropped[name] = dropped.get(name, 0) + 1
        text = (x.get("text") or "").strip()
        if qs and text:
            out.append({"state": text[:max_chars], "qs": qs, "src": x.get("id", "")})
    if dropped and not quiet:
        detail = ", ".join("%s: %d" % kv for kv in sorted(dropped.items()))
        print("  WARNING: %d labels in %s do not fit their question definition "
              "and were skipped (%s)" % (sum(dropped.values()), path, detail),
              flush=True)
    return out


if __name__ == "__main__":
    import collections, sys
    if len(sys.argv) < 3:
        sys.exit("usage: python data.py <records.jsonl> <questions.json>")
    defs = load_questions(sys.argv[2])
    r = records(sys.argv[1], defs)
    print("questions:", list(defs))
    print("records:", len(r))
    print("questions per record:", dict(collections.Counter(len(x["qs"]) for x in r)))
    if r:
        print("median state length:", sorted(len(x["state"]) for x in r)[len(r) // 2])