Skip to main content

Programming language for analyzing mathematical numerical problem's solutions

Project description

Numlab

Contenidos

Objetivos

El objetivo de este proyecto es crear una herramienta de aprendizaje desarrollada para ayudar a los estudiantes de Ciencias de la Computación que están cursando la asignatura Matemática Numérica. Se quiere crear un lenguaje basado en Python en el cual los estudiantes implementen soluciones de problemas numéricos como: hallar cero de funciones, resolver sistemas de ecuaciones, entre otros; y a la vez, realizar un análisis de la ejecución de estas soluciones.

Una de las características que tendrá este lenguaje es la capacidad de simular regiones de código bajo ciertas condiciones o limitantes, como por ejemplo: limitar el tiempo de ejecución, limitar la cantidad de variables que se pueden crear, lmitar el tiempo que toma realizar una operación determinada, entre otras. Esto fomenta en los estudiantes la búsqueda de soluciones más eficientes. Además, el lenguaje también constará con la posibilidad, mediante un algoritmo genético, de optimizar códigos ya escritos.

¿Cómo instalar numlab?

pip install numlab

Para más información sobre el uso del programa:

numlab --help

También se encuentra una breve descripción en la sección Aplicación CLI

Lenguaje

Numlab es el lenguaje que se ha implementado para dar solución a los objetivos mencionados anteriormente. En las siquientes secciones, se muestran las principales características del mismo.

Características básicas

Numlab está basado en Python, aunque no implementa todas las funcionalidades del mismo, la sintaxis básica sí es la misma. Ejemplos:

# Declaración de variables
a = 5
pi = 3.14
name = "John"
val = True 

# Funciones built-in
print("my name is", name)

# Operaciones aritméticas
print(a + pi)
print(a - pi)
print(a ** 2)

# Control de flujo
if val:
    print("val is true")
else:
    print("val is false")

for i in range(10):
    if i % 2 == 0:
         print(i)


# Declaración de funciones
def foo(a, b):
    return a + b

bar = lambda a, b: a + b

# Declaración de tipos
class Foo:
    def __init__(self, a, b):
        self.a = a
        self.b = b

Estadísticas en tiempo de ejecución

En cada ejecución existe una variable llamada stats, la cual contiene un diccionario con las estadísticas de la ejecución. Entre las estadísticas que se pueden obtener se encuentran:

  • stats["time"]: tiempo de ejecución en segundos.
  • stats["assign_count"]: cantidad de asignaciones realizadas.
  • stats["var_count"]: cantidad de variables creadas.
  • stats["call_count"]: cantidad de llamados a funciones realizados.
  • stats["add_count"]: cantidad de veces que se realizó la operación suma.
  • stats["sub_count"]: cantidad de veces que se realizó la operación resta.
  • stats["mul_count"]: cantidad de veces que se realizó la operación multiplicación.
  • stats["truediv_count"]: cantidad de veces que se realizó la operación división.
  • stats["pow_count"]: cantidad de veces que se realizó la operación potencia.
  • stats["mod_count"]: cantidad de veces que se realizó la operación módulo.
  • stats["floordiv_count"]: cantidad de veces que se realizó la operación división entera.
  • stats["lshift_count"]: cantidad de veces que se realizó la operación left shift.
  • stats["rshift_count"]: cantidad de veces que se realizó la operación right shift.
  • stats["matmul_count"]: cantidad de veces que se realizó la operación multiplicación de matrices.
  • stats["bit_xor_count"]: cantidad de veces que se realizó la operación bitwise xor.
  • stats["bit_and_count"]: cantidad de veces que se realizó la operación bitwise and.
  • stats["bit_or_count"]: cantidad de veces que se realizó la operación bitwise or.
  • stats["contains_count"]: cantidad de veces que se comprobó si un elemento está contenido en otro.
  • stats["eq_count"]: cantidad de veces que se comprobó si dos elementos son iguales.
  • stats["ne_count"]: cantidad de veces que se comprobó si dos elementos son distintos.
  • stats["lt_count"]: cantidad de veces que se comprobó si un elemento es menor que otro.
  • stats["gt_count"]: cantidad de veces que se comprobó si un elemento es mayor que otro.
  • stats["le_count"]: cantidad de veces que se comprobó si un elemento es menor o igual que otro.
  • stats["ge_count"]: cantidad de veces que se comprobó si un elemento es mayor o igual que otro.
  • stats["and_count"]: cantidad de veces que se realizó la operación lógica and.
  • stats["or_count"]: cantidad de veces que se realizó la operación lógica or.

Ejemplo:

for i in range(10):
    a = i * 2

print(stats["mul_count"])  # Output: 10

En cualquier momento de la ejecución, se puede restablecer todas las estadísticas a cero usando la palabra clave resetstats.

Ejemplo:

for i in range(10):
    a = i * 2
    if i == 4:
        resetstats

print(stats["mul_count"])  # Output: 5

Simulación de código

En Numlab es posble simular regiones de código bajo diferentes restricciones, como lo son: limitar la cantidad de variables que se pueden crear (max_var_count) y limitar el tiempo de ejecución (max_time), limitar la cantidad de veces que se realiza una operación determinada y establecer el tiempo que toma en realizarse una operación.

El nombre de las restrcciones relacionadas con operadores se nombran: max_<operator>_count y <operator>_time para establecer la catidad máxima de veces que se puede realizar una operación y el tiempo que toma en realizarse respectivamente. Por ejemplo, si se desea limitar la cantidad de veces que se realiza la operación suma: max_add_count.

Configuración de la simulación

Para establecer estas restricciones en Numlab las mismas se deben escribir en un bloque de configuración:

conf general_conf:
    max_time 0.5
    max_var_count 10
    max_add_count 100
    max_sub_count 100
    sub_time 0.1

Es posible también crear jerarquías de restricciones, por ejemplo:

conf general_conf:
    max_time 0.5
    max_var_count 10
    max_add_count 100
    max_sub_count 100
    sub_time 0.1

conf add_config1(general_conf):
    add_time 0.3

conf add_config2(general_conf):
    add_time 0.5

Estas configuraciones heredan las restricciones de la configuración base. De esta forma se pueden declarar configuraciones diferentes pero que tengan restricciones en común sin necesidad de repetir las mismas. Si se establece una restricción que ya estaba en la configuración base, se sobreescribe.

Los valores de una configuración pueden ser resultados de una operación, por ejemplo:

a = 2

conf c1:
    max_time a ** 3 + 8

Si la restricción es sobre el tiempo de ejecución de un operador, es posible también especificar en vez de un valor, una función que devuelva el tiempo de ejecución de la operación. Esta función se ejecuta cada vez que se realiza la operación determinada y debe devolver el tiempo que debe durar la operación. Por ejemplo:

conf c1:
    add_time lambda : rand() * 2 

rand es una función built-in que devuelve un número aleatorio entre 0 y 1 con una distribución uniforme.

En el ejemplo anterior, cada vez que se simule la operación suma, la misma demorará un tiempo aleatorio entre 0 y 2.

Regiones de código simuladas

Para establecer una región de código donde se simulen las restricciones establescidas en una configuración, se utilizan las palabras claves begsim <config> y endsim, donde <config> es el nombre de la configuración que se desea usar. Estas palabras indican donde comienza y termina la simulación respectivamente. Por ejemplo:

conf c1:
    max_time 0.5

begsim c1
a, b = 1, 1
print(a)
print(b)
for _ in range(98):
    a, b = b, a + b
    print(b)
endsim

Es posible utilizar begsim y endsim más de una vez en una misma ejecución (en tal caso se recomienta usar resetstats antes de cada empezar de simulación). Se pueden relizar simulaciónes dentro de simulaciones, en tal caso se pasa a utilizar la configuración de la última simulación. Cada endsim termina un nivel de simulación (la última que se haya hecho). Estas palabras claves pueden estar en cualquier parte del código, la única restricción es que no se pueden ejecutar más endsim que begsim.

Implementación

Numlab es un lenguaje evaluado escrito en Python. A continuación se exponen las características principales de la implementación de cada estapa.

Autómatas

Para la creación de las algunas de las proximas funcionalidades, se realizaó una implementación de un tipo Automata que permite simular una máquina de estados de forma genérica. A los mismos se le pueden agregar estados así como transiciones entre los mismos. Cada autómata tiene un estado inicial y uno o varios estados finales.

La ejecución de una máquina de estados realizada con un autómata es bastante simple. Dado una entrada iterable, se comienza en el estado inicial y se va ejecutando cada transición hasta llegar a un estado final. En caso de llegar a un estado en el que ninguna transición es válida, se termina la ejecución y la entrada no es válida. En caso de terminar de recorrer la entrada se clasifica la entrada como válida o inválida en dependencia de si se llegó a un estado final o no respectivamente.

Los autómatas pueden tener transiciones épsilon entre estados, en este caso, la ejecución se bifurca y la maquina de estados se mueve por todos los estaos posibles al mismo timepo. Esto da la posibliadad de ejecutar autómatas no deterministas.

Se implementó además, utilizando el algoritmo visto en clase (calculando los goto y epsilon clausuras) la opción de convertir un autómata no determinista (NFA) a un autómata determinista (DFA).

Motor de expresiones regulares

Las principales funcionalidades implementadas son:

  • Operador *: Matchea cero o más veces la expresión anterior.
  • Operador |: Mathcea la expresión anterior o la siguiente.
  • Operador ^: Matchea cualquier expresion excepto la expresión que le prosigue.
  • Caracter .: Matchea cualquier caracter (ASCII).
  • Caracter \: Inicio de un caracter especial.
  • Caracter \d: Matchea un dígito.
  • Caracter \a: Matchea una letra minúscula.
  • Caracter \A: Matchea una letra mayúscula.
  • Parentesis ( y ): Agrupan una expresión regular.

Cualquier operador o caracter especal puede ser escapado con \.

Para la realización del motor de expresiones regulares se utilizó la clase Automata. Para cada expresión regular se construye un autómata finito no determinista (NFA) usando el algoritmo de Thompson y luego el mismo se convierte a un DFA utlizando el método to_dfa de la clase Automata.

Se ofrecen además dos funciones para el matcheo de cadenas segun una expresión regular: match (la cual tiene un comportamiento similar a re.match) y compile_patt (la cual tiene un comportamiento similar a re.compile). La ventaja principal de usar compile_patt es que se no es necesario crear un autómata para cada vez que se desea matchear una cadena (ya que el autómata es construido una sola vez).

Tokenizador

Para la implementación del tokenizador se creó una clase Tokenizer. Esta clase se encarga de tomar un texto y dividirlo en diferentes tipos de tokens. Cada patrón que se agrega está definido por un nombre (tipo del token) y una expresión regular (se hace uso del motor de expresiones regulares implementado).

tknz = Tokenizer()
tknz.add_pattern("NUMBER", r"\d\d*|\d\d*\.\d\d*")

Al tokenizar un texto, se revisan los patrones comenzando por el primero (en el mismo orden en el que fueron agregados) y el primero que matchee con un prefijo de la cadena se establece como un token nuevo (se toma como lexema la subcadena que matcheó con la expresión regular). Luego se vuelve a realizar esta operación con el resto de la cadena, así sucesivamente hasta terminar la misma. Si en algún punto no se encuentra un token que matchee con el inicio de la cadena, se considera que la cadena no se puede tokenizar (con los tipos de tokens establecidos).

Cada vez que se agrega un patrón al tokenizador se puede establecer una función que se aplicará al lexema antes de guardar su valor en el token.

Por ejemplo, para quitar las comillas al tokenizar un string:

tknz.add_pattern("STRING", r"'((^')|(\\'))*(^\\)'", lambda t: t[1:-1])

Esta función tambien puede ser utilizada para indicar que se quiere ignorar los tokens de un tipo determinado. En tal caso basta con que la función devuelva None:

tknz.add_pattern("SPACE", r"( | \t)( |\t)*", lambda t: None)

Se ofrece también la opción de agregar keywords (palabras claves) para una mayor comodidad. Esto se hace mediante el método add_keywords() el cual recibe una lista de palabras. En el proceso de tokenización, si el prefijo matcheado conicide con alguna de las palabras clave, entonces el tipo del token se establece como KEYWORD.

En caso de que se quiera aplicar una función para procesar todos los tokens obtenidos, se puede usar el decorador process_tokens de la clase Tokenizer. Este debe ser usado en una función que reciba un solo argumento (la lista de tokens) y devuelva una lista de tokens procesados.

@tknz.process_tokens
def process_tokens(tokens):
    # ...
    return tokens

Finalmente, para obtener los tokens de un texto basta con usar la función tokenize:

tokens = tknz.tokenize("some text")

Gramáticas

Se implementaron las clases Grammar, NonTerminal, Terminal y Production las cuales son usadas para la representación de una gramática general. Se implementó además un parser de gramáticas con el cual es posible crear gramáticas dado un formato, esto permite definir la gramática del lenguaje en un archivo y poder cambiarla fácilmente. Dado la sencillez del formato (el lenguaje de las gramáticas), se implementó un sencillo parser recursivo descendente para la creación de las mismas.

El formato especificado es el siguiente:

expression: production_1 | production_2 | ... | production_n

De forma equivalente, para mayor legibilidad:

expression:
    | production_1 
    | production_2
    | ...
    | production_n

Ejemplo:

ExprAB:
    | 'a' ExprAB 'b'
    | EPS

EPS es un elemento especial en las gramáticas para representar epsilon

Las gramáticas luego pueden ser cargadas como se muestra a continuación:

from grammar im port Grammar
gm = Grammar.open("expr_ab.gm")

Las gramáticas están compuestas por una lista de expresiones (no terminales). Cada no terminal de la gramática, contiene una lista de producciones. Cada producción contiene una lista de elementos (terminales o no terminales).

Árbol de Sintaxis Abstracta (AST)

Para la creación de un AST se creó la clase abstracta AST. De esta clase heredan todos las clases que representan los nodos del árbol de sintaxis abstracta del lenguaje. En la clase se implementa también un método dump que permite mostrar el árbol de forma legible. Este método usa el atributo __slots__ mediante el cual se definen los atributos que se quieren mostrar.

Ejemplo del árbol generado a partir del código:

conf c1:
    max_time 5
    max_var_count 5

begsim c1
def foo(a, b):
    print("hola", a, b)

a, b = 1, 2
foo(a, b)
endsim

Árbol generado:

Program:
   stmts: [
      ConfDefStmt:
         name: c1
         configs: [
            ConfOption:
               name: max_time
               value: ConstantExpr(0.5)
         ]
      Begsim:
         config: NameExpr('c1', ctx=ExprCtx.LOAD)
      ForStmt:
         target: NameExpr('i', ctx=ExprCtx.LOAD)
         iter_expr: (TupleExpr)
            elts: [
               CallExpr:
                  func: NameExpr('range', ctx=ExprCtx.LOAD)
                  args: [
                     ConstantExpr(100)
                  ]
            ]
            ctx: ExprCtx.LOAD
         body: [
            TupleExpr:
               elts: [
                  CallExpr:
                     func: NameExpr('print', ctx=ExprCtx.LOAD)
                     args: [
                        BinOpExpr:
                           left: NameExpr('i', ctx=ExprCtx.LOAD)
                           op: Operator.POW
                           right: ConstantExpr(2)
                     ]
               ]
               ctx: ExprCtx.LOAD
         ]
      Endsim:
   ]

Para definir cómo se construye cada nodo del AST se pueden asignar los constructores a cada producción de la gramática usando la función assign_builders. Esta función recibe un diccionario donde las llaves son la representación textual de la producción y los valores son funciones que reciben como argumentos los elementos de la producción. En caso de que el símbolo sea un terminal la función recibirá dicho terminal, en caso de ser un no terminal, la función recibirá el resultado de la ejecución algunas de las funciones constructoras de las producciones que tengan como cabeza a dicho no terminal.

Por ejemplo, a continuación se muestran algunos de los constructores para la gramática de Numlab:

builders = {
    # -------------------------------------------------------------------------
    "program -> stmt program": lambda s, p: ast.Program([s] + p.stmts),
    "program -> NEWLINE program": lambda n, p: p,
    "program -> EPS": lambda: ast.Program([]),
    # -------------------------------------------------------------------------
    "stmt -> simple_stmt": lambda s: s,
    "stmt -> compound_stmt": lambda c: c,
    # -------------------------------------------------------------------------
    "stmt_list -> stmt": lambda s: [s],
    "stmt_list -> stmt stmt_list": lambda s, sl: [s] + sl,
    # -------------------------------------------------------------------------
    # ...
    # ...

Parser

Para la implementación del parser principal del lenguaje se creó la clase abstacta Parser. Usando esta clase como base se creó una clase LR1Parser, la cual implementa un parser LR(1).

Para la realización del parser LR(1) fue necesario implementar las clases LR1Item y LR1Table. La primera de estas clases representa un item del parser, el cual contiene: la producción que lo genera, la posición del punto (dot) en la producción y el terminal que le debe proseguir (lookahead).

La segunda clase (LR1Table) representa la tabla de transición del parser. Cada posición de la tabla puede contener tres tipos de elementos: un string "OK", que indica que el estado de aceptación; un valór numérico entero, que indica cual es el siguiente estado; o un no terminal de la gramática, el cual representa que hay que realizar una reducción. Para no tener que recalcular la tabla cada vez que se va a parsear un texto, la misma puede ser serializada y luego cargada.

La construcción de la tabla se realizó siguiendo el algoritmo visto en las conferencias de la asignatura (calculando los goto y las clausuras de los estados).

En el proceso de parsing, al realizar una acción de reducción, es donde se utilizan las funciones constructoras vistas en la sección anterior. En dependencia de la producción que se está reduciendo, se llama a la función constructora correspondiente.

Para una mayor comodidad se implementó también la clase ParserManager. Esta clase ofrece, dado una gramática, un tokenizador (opcional) y un parser (opcional, por defecto LR(1)), métodos como: parse_file (para parsear un archivo), parse (para parsear un texto) y parse_tokens (para parsear una lista de tokens directamete). Estas funciones devuelven el AST resultante del proceso de parsing.

Visitors

Una vez obtenido el AST de un programa es necesario realizar recorridos sobre él. Para ello se implmentó una clase Visitor la cual contiene dos decoradores @visitor y @callback. Por cada visitor que se quiera implementar para el AST, se debe implementar una nueva clase que tenga como atributo de clase una instancia de la clase Visitor. Luego, cada método de la clase que tenga el decorador @visitor, se establecerá como una sobrecarga. Es por ello que todos estos métodos deben tener sus argumentos tipados (esta es la forma en la que el visitor sabe cual de los métodos de la clase debe llamar).

Por ejemplo:

from numlab.lang.visitor import Visitor

class EvalVisitor:
    visitor_dec = Visitor().visitor

    @visitor_dec
    def eval(self, node: ast.Program):
        for stmt in node.stmts:
            stmt.eval(self)

    @visitor_dec
    def eval(self, node: ast.Stmt): ...

    # ...

El decorador @callback se utiliza para definir funciones que se van a llamar cada vez que se llame a una función marcada como visitor. En el proyecto uno de los usos que se le da a este decorador es para comprobar que el tiempo de ejecución de una simulación es menor que el límite establecido en cada momento.

Ejecución

Para la ejecución de un programa representado en un AST se implementó un visitor EvalVisitor (muy similar al ejemplo de la sección anterior). A continuación se muestran algunas de las características más importantes implementadas en el proceso de evaluación.

Tipos y funciones predefinidas

Para representar los tipos predefinidos de Numlab se creó la clase Type. Cada instancia de esta clase representa un tipo de Numlab, entre ellos: int, str, list, etc (los tipos básicos existentes en python).

A cada tipo de le pude agregar atributos (las funciones también se agregan como atributo). Además, cada tipo puede derivar de otro (permitiendo la herencia), por consiguiente, en la resolución de un atributo si el mismo no está definido en el tipo actual, se busca en el tipo padre (y así susesivamente).

Se implementaron tambíen varias de las funciones built-in de python en Numlab. En la ejecución del código se puede acceder a estas funciones directamente.

Contextos

Para definir el contexto donde se encuentra cada variable, tipo o función que se crea, se implementó la clase Context. Un context tiene un diccionario con el nombre de cada objeto creado en el mismo y su respectivo valor. Cada contexto tiene además una referencia al contexto padre (en caso de que exista). Esto permite que al realizar la resolución de una variable, si la misma no se ecuentra en el contexto actual, se busque en el contexto padre.

En el EvalVisitor al realizar la resolución de una variable, se busca primero en el context actual, y si no se encuentra, se busca entre las funciones y tipos predefinidos.

El contexto también es utilizado también en las secciones de código simuladas para comprobar la cantidad de variables creadas (en caso de existir alguna restricción sobre este valor).

Evaluación

Como se había mencionado anteriormente, la evaluación de un programa se realiza mediante un visitor. En este visitor se implementaron las funciones que definen cómo se evalúa cada uno de los nodos del AST.

En ocasiones, existen nodos que afectan la evaluación de otros, como por ejemplo las palabras claves de control de flujo (break, continue, return, etc). Para ello, el EvalVisitor cuenta con un diccionario flags (atributo de instancia) que contiene diversa información que se puede utlizar en común entre la evaluación de los nodos.

En estas evaluaciones es también donde se van guardando las estadísiticas de la ejecución del programa (en un diccionario stats el cual tiene accesibilidad incluso desde el código que se está ejecutando). Por ejemplo, al realizar un llamado a una función, se incrementa el contador de llamados:

class EvalVisitor:
    
    # ... Other eval methods

    @visitor
    def eval(self, node: ast.CallExpr):
        self.set_stat("call_count", self.stats["call_count"] + 1)
        # Call eval implementation ...

    # ... Other eval methods

Optimización de código

Se implementó también un optimizador de código, el cual, mediante un algorítmo genético, cambia la estructura de un AST para reducir el tiempo de ejecución. Para ello se creó otro visitor, el cual se puede clasificar como un sistema experto. Este visitor, busca en un programa determinado en qué nodos se pueden realizar, bajo ciertas reglas, cambios que puedan reducir el tiempo de ejecución.

Entre estas reglas se pueden mencionar por ejemplo, cambiar el orden de las comprobaciónes en una condicional (o ciclo while) para que se evalúen primero las condiciones que son más sencillas, o incluso, sustituir operaciones de multiplicación por operaciones shift si es posible, etc.

Una vez se recorre el AST en busca de cambios, se crea un vector el cual contiene en cada posición la información de dónde se puede realizar un cambio (el nodo) y dos funciones: una que realiza el cambio y otra que devuelve el nodo a su estado original.

Esta información se lleva a la ejecución de un algorítmo genético. La población de este algoritmo consiste en vectores booleanos que indican si se debe realizar un cambio en un nodo o no. Al evaluar un AST se realizan los cambios necesarios y se obtiene el tiempo de ejecución (en caso de existir un error el tiempo de ejecución será infinito).

La población inicial se genera aleatoriamente. El entrecruzamiento entre dos vectores se realiza dividiéndolos en dos partes, he intercambiando las mismas. Esta forma de entrecruzamiento es posible ya que cada cambio es independiente. La mutación consiste en cambiar aleatoriamente algún valor del vector.

Al ejecutar la optimización se obtiene el AST resultante de realizar los cambios definidos por el mejor vector de la población (luego de algunas generaciones).

Los parámetros usados en el algoritmo genético pueden ser modificados directamente desde el commando optimize del CLI o mediante un archivo de configuración .json. Estos parámetros son:

  • pop_size: Tamaño de la polbación.
  • max_iter: Número máximo de generaciones.
  • mutation_prob: Probabilidad de mutación.
  • best_sel_count: Número de mejores individuos que se seleccionan para el siguiente ciclo.
  • new_random_count: Número de nuevos individuos que se generan aleatoriamente para el siguiente ciclo.

Sistema difuso para la estimación de la calidad de la optimización

Dado que optimizar un código conlleva la evaluación del mismo varias veces, se implementó un sistema difuso para estimar que tan buena puede ser la calidad de la optimización antes de realizarla. A cada cambio posible se le asigna una puntuación de calidad (entre 0 y 1) y finalmente se devuelve la mejor puntuación obtenida.

Cada cambio tiene dos valores asociados:

  • Valor principal: Puntuación que se asigna al cambio.
  • Profundidad en loops: Número de loops en los que se encuentra el cambio.

El valor principal varía en dependencia del cambio en sí. Por ejemplo, un cambio de una multiplicación que puede convertirse en un shift no tiene un valor tan alto como cambiar el orden de las condiciones en un if. Así como cambiar el orden de las condiciones en un while tiene más valor que cambiarlas en un if.

La calidad de cada cambio es estimada por el sistema difuso siguiendo 3 reglas principales:

  • El cambio tiene baja puntuación o bajo nivel de profundidad en ciclos (cambio pobre).
  • El cambio tiene puntuación media o un nivel medio en profundidad en ciclos (cambio aceptable).
  • El cambio tiene alta puntuación o alto nivel de profundidad en ciclos (buen cambio).

Para ello, se implementó una clase FuzzyRule que representa una regla del sistema difuso. En cada regla se guardan las funciones que se deben comprobar para cada valor (en nuestro caso funciones de tipo campana de Gauss variando su media y variaza), así como la función que se debe aplicar a los valores resultantes (en nuestro caso la función max, ya que todas las reglas utilizan solo el operador OR).

Toda la implementación del sistema se realizó en la clase FuzzyOptClassifier. En la obtención de los consecuentes de las reglas se utiliza el método de Mamdani (truncar). En el caso de la desfusificación de los cambios se utiliza el método de media de los valores máximos.

Ejemplo de optimización

A continuación se muestra un ejemplo de código y su optimización:

items = [1, 1, 1, 1, 1]

def foo():
    a = [i for i in range(100)]
    return items

for i in range(50):
    if i in [j for j in range(48, 500)] or i < 40: 
        a = i + 3

    if foo() and items[0] == 1:
        items.remove(1)

print(stats["time"])

En este código se puede ver que el primer if realiza dos comprobaciones. La primera de ellas genera una lista de al rededor de 450 elementos cada vez que se ejecuta el if, mientras que la segunda solamente compara dos valores.

Se puede observar además que existe otra comprobación, donde también se realizan dos operaciones y la primera de ellas es más costosa temporalmente que la segunda. Sin embargo en este caso la segunda operación es dependiente de la primera, por lo que no se puede realizar antes. Esto no se comprueba en ningún momento al extraer los cambios posibles debido a la complegidad que puede presentar comprobar este tipo relaciones entre los nodos. Más adelante el algoritmo genético es quien decidirá si es correcto realizar el cambio o no.

Al ejecutar este código se obtuvo un tiempo de ejecución promedio de 1.8 segundos. Al optimizar el AST, se detectaron dos cambios potenciales (el orden de comprobación en las expresiones condicionales mencionadas anteriormente). El valor de la calidad de la optimización estimado fue aproximadamente 0.6. El algoritmo genético obtuvo como mejor resultado realizar el primer cambio y no el segundo. Finalmente, el código optimizado tuvo un tiempo de ejecución de aproximadamente 0.6 segundos.

$ numlab optmize "numlab_test.nl"

 .===============================================.
 |                 Configuration                 |
 |===============================================|
 | Parameter                               Value |
 |-----------------------------------------------|
 | Population size                            10 |
 | Maximum number of iterations                5 |
 | Mutation probability                      0.1 |
 | Number of best individuals to select        3 |
 | Number of new random individuals            2 |
 '==============================================='


Estimated optimization quality: 0.6034089537672265

Do you want to proceed with the optimization? [Y/n]: 
Output (before optimizing):
1.8277204990386963

Optimizing program
Generating population of 10
Evaluating population
Iteration 1 of 5
Best solution: ([1, 0], 0.7729673385620117)
Iteration 2 of 5
Best solution: ([1, 0], 0.7729673385620117)
Iteration 3 of 5
Best solution: ([1, 0], 0.6900575160980225)
Iteration 4 of 5
Best solution: ([1, 0], 0.6900575160980225)
Iteration 5 of 5
Best solution: ([1, 0], 0.6900575160980225)

Output (after optimizing):
0.6344912147521973

Extras

Aplicación CLI

Al instalar Numlab es posible usar una interfaz de comando mediente la terminal. La misma cuenta con dos comandos principales: run y optimize, las cuales ejecutan u optimizan un programa respectivamente.

$ numlab --help
Usage: numlab [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  optimize  Optimize a program given in the input file
  run       Run the program given in the input file
  version   Print the version number

Ejemplo:

numlab "my_script.nl" --verbose

Para más información sobre los comandos:

numlab run --help
numlab optimize --help

Extensión de VSCode

Una extensión de VSCode fue desarrollada también para el uso del lenguaje. La misma aporta highlighting de las palabras claves del lenguaje para los archivos .nl.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

numlab-0.2.2.tar.gz (281.7 kB view hashes)

Uploaded Source

Built Distribution

numlab-0.2.2-py3-none-any.whl (283.3 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page