File size: 6,683 Bytes
2695555 | 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 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """Murph (3.0.4): a ajudante que decide a TAREFA de cada pedido.
Regressão logística (softmax) sobre pedaços de palavra (2 a 5 letras),
palavras e pares de palavras, com peso TF-IDF. Diferente do Naive Bayes
atual, os pesos são aprendidos DISCRIMINANDO as tarefas (o que separa
"desliga o bluetooth" de "o bluetooth não funciona"), e as palavras curtas
("isso", "that", "aí") contam: elas são pista de visão.
Treinada com os exemplos do openTARS mais ~2.300 frases novas escritas
por um modelo grande nos 5 idiomas (a receita do TinyStories: o modelo
grande escreve, o pequeno aprende). Frases parecidas com as de avaliação
ficaram de fora do treino.
Rodar só precisa de Python puro (~0,2 ms por pedido). O treino usa
scikit-learn e fica em murph/treinar_murph.py (não vai no pacote).
Entrou no lugar do Naive Bayes (tars_classificador, removido na Endurance Neo) como camada 4 da
escolha da tarefa; sem o modelos/murph.json, o openTARS volta pro NB.
"""
import json
import math
import os
import re
import threading
import unicodedata
VERSAO_FORMATO = 1
VERSAO = "1.0" # helper Murph 1.0 (3.0.5): mostrado na janela e no terminal
N_GRAMAS = (2, 3, 4, 5)
_RE_PALAVRA = re.compile(r"\w+", re.UNICODE)
def normalizar(texto):
s = unicodedata.normalize("NFKD", texto or "")
s = "".join(ch for ch in s if not unicodedata.combining(ch))
return re.sub(r"\s+", " ", s.lower().replace("ß", "ss")).strip()
def contagens(texto):
"""{característica: contagem} (antes do TF-IDF)."""
t = normalizar(texto)
palavras = _RE_PALAVRA.findall(t)
feats = {}
for w in palavras:
feats["w:" + w] = feats.get("w:" + w, 0) + 1
marcada = f" {w} "
for n in N_GRAMAS:
for i in range(max(0, len(marcada) - n + 1)):
k = "c:" + marcada[i:i + n]
feats[k] = feats.get(k, 0) + 1
for a, b in zip(palavras, palavras[1:]):
k = f"b:{a}_{b}"
feats[k] = feats.get(k, 0) + 1
n = len(palavras)
feats["#n" + ("1" if n <= 1 else "2" if n <= 3 else "6" if n <= 6 else "+")] = 1
return feats
def cobertura(texto, idf):
"""Quanto do texto (pedaços de palavra e palavras) o modelo já viu no
treino, de 0 a 1. "asdfgh" ou uma língua que ela não conhece: baixo."""
c = contagens(texto)
total = sum(n for k, n in c.items() if not k.startswith("#"))
if not total:
return 0.0
return sum(n for k, n in c.items() if not k.startswith("#") and k in idf) / total
def vetor(texto, idf):
"""Características com TF sublinear x IDF, normalizadas (L2).
Só entra o que o modelo conhece (idf)."""
v = {}
for k, c in contagens(texto).items():
peso = idf.get(k)
if peso is not None:
v[k] = (1.0 + math.log(c)) * peso
norma = math.sqrt(sum(x * x for x in v.values())) or 1.0
return {k: x / norma for k, x in v.items()}
class Murph:
def __init__(self, dados):
self.tarefas = dados["tarefas"]
self.idf = dados["idf"]
self.vies = dados["vies"]
# pesos: {característica: [peso por tarefa]}
self.pesos = dados["pesos"]
self.limite = float(dados.get("limite", 0.1))
self.cobertura_minima = float(dados.get("cobertura_minima", 0.0))
self.versao = str(dados.get("versao") or VERSAO)
@classmethod
def carregar(cls, caminho):
with open(caminho, encoding="utf-8") as f:
dados = json.load(f)
if dados.get("formato") != VERSAO_FORMATO:
raise ValueError("formato de modelo desconhecido")
return cls(dados)
def probabilidades(self, texto):
"""[(probabilidade, tarefa)] da mais provável pra menos."""
notas = list(self.vies)
for k, x in vetor(texto, self.idf).items():
linha = self.pesos.get(k)
if linha:
for i, w in enumerate(linha):
notas[i] += x * w
topo = max(notas)
exps = [math.exp(n - topo) for n in notas]
soma = sum(exps)
return sorted(((e / soma, t) for e, t in zip(exps, self.tarefas)), reverse=True)
def classificar(self, texto):
"""(tarefa, confiança 0..1)."""
p = self.probabilidades(texto)
return p[0][1], p[0][0]
# ------------------------------------------------------------
# Uso no openTARS (mesmo formato do antigo tars_classificador.classificar)
# ------------------------------------------------------------
CAMINHO_PADRAO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "modelos", "murph.json")
_estado = {"modelo": None, "falhou": False}
_trava = threading.Lock()
def preparar(caminho=None):
"""A Murph carregada (~0,3 s na primeira vez) ou None se o arquivo não
existe / está estragado (aí quem chama usa o Naive Bayes)."""
if _estado["modelo"] is not None or _estado["falhou"]:
return _estado["modelo"]
if os.environ.get("TARS_MURPH", "").lower() in ("0", "off", "no", "nao", "não"):
return None # TARS_MURPH=off: usa o Naive Bayes (pra comparar)
with _trava:
if _estado["modelo"] is None and not _estado["falhou"]:
try:
_estado["modelo"] = Murph.carregar(caminho or os.environ.get("TARS_MURPH_ARQUIVO") or CAMINHO_PADRAO)
except Exception: # arquivo faltando ou estragado: volta pro Naive Bayes
_estado["falhou"] = True
return _estado["modelo"]
def disponivel():
return preparar() is not None
def versao():
""""1.0" (a versão gravada no modelo, ou a deste arquivo)."""
m = preparar()
return getattr(m, "versao", None) or VERSAO
def classificar(texto):
"""{"notas": [(nota, tarefa)...], "margem": folga da 1ª, "certo",
"limite"} — igual ao antigo tars_classificador, pra entrar no mesmo lugar.
A nota é a probabilidade menos a da 1ª (a 1ª fica com 0)."""
m = preparar()
if m is None or not normalizar(texto):
return None
p = m.probabilidades(texto)
topo = p[0][0]
folga = topo - p[1][0] if len(p) > 1 else 1.0
limite = m.limite
# Segura = folga grande E o pedido parece com o que ela aprendeu. Texto
# estranho (letras aleatórias, outra língua) fica "em dúvida" mesmo com
# folga: aí as camadas seguintes (apps, contexto) decidem.
visto = cobertura(texto, m.idf)
return {"notas": [(prob - topo, t) for prob, t in p], "margem": folga,
"certo": folga >= limite and visto >= m.cobertura_minima, "limite": limite,
"probabilidade": topo, "cobertura": visto}
def esquecer():
"""Só pros testes."""
_estado["modelo"] = None
_estado["falhou"] = False
|