Pages

Wednesday, July 21, 2010

Demonstration python decorator

I feel that python decorators allow programmers in python to code in a very natural way. Since a ton of blogs out there already have documented what python decorators precisely are or aren't, so I won't go into too much detail here. I probably won't do a good job explaining what they are.

Ah what the heck, here goes my explanation:

Babushka (grandma) matryoshka dolls
Imagine that a decorated function is like a matryoshka doll, a matryoshka doll can be enclosed around with another doll. Python experts might not agree with this analogy, because when a function is decorated it can be accessed by the function outside it, so it does stand dormant in a dark place like a matryoshka doll.

See the following function declarations:

def wrap(f)
  # Add before behaviour
  print "start wrap"
  # Do something with f
  # Add after behaviour
  print "end wrap"
  return f # Pass f to the next label or babushka/function

@wrap
def fun():
  pass # do something interesting...

The previous rows are equivalent to
fun = wrap(fun)

The wrapping function (wrap) must return a final function to relabel the newly decorated function with the original unwrapped label, which is fun in our example.

You could also decorate more than once:
@huge
@medium
@small
def core()
  pass

Which is equivalent to the following:
core = huge(medium(small(core)))
In this example, one must also keep passing the decorated function to the next decorator with a return statement.

This year I decided to improve my mental arithmetic skills, so I wrote a python application to train on. I was also quite curious as to what the whole python decorator thing was about. I combined my curiosity for python decorators and the desire for a mental arithmetic trainer. The following script is the (partial) end result.

def timeit(fun):
  from time import time
  def timed(*args):
    ts = time()
    mistakes, op_symbol = fun(*args)
    te = time()
    time_elapsed = te - ts
    print "Time elapsed:", time_elapsed
    return time_elapsed, mistakes, op_symbol
  return timed

class Query(object):
  def __init__(self, op_symbol):
    self.op_symbol = op_symbol

  def __call__(self, f):
    # NOTE: you can count the function arguments later to try on a n-ary functions
    def wrapped_binary_f(*args):
      a,b = tuple(args)
      #print "%s %s %s = ?" % (a, self.op_symbol, b) # infix notation
      print a, self.op_symbol, b, " = ?"
      correct_answer = f(a,b)
      user_answer = None
      mistakes = 0
      while not user_answer or user_answer != correct_answer:
        user_answer = None
        try:
          user_answer = input("answer: ")
          if user_answer != correct_answer:
            mistakes+=1
            print "Please try again... %s" % str(mistakes)
          else:
            print "Correct."
            break
        except SyntaxError:
          mistakes+=1
          print "Please try again... %s" % str(mistakes)
        except NameError:
          mistakes+=1
          print "Please try again... %s" % str(mistakes)
      return mistakes, self.op_symbol
    return wrapped_binary_f
    
@timeit
@Query('*')
def multiply(a, b):
  return a * b
  
@timeit
@Query('-')
def subtract(a, b):
  return a - b
  
@timeit
@Query('+')
def add(a, b):
  return a + b

This example demonstrates three techniques, namely:
  1. Passing arguments to a decorator function (Query class's __init__ function);
  2. Using multiple decorator functions on multiple functions (timeit, Query);
  3. Manipulating arguments passed to the decorator function and the called functions (multiply, subtract, add) and mixing them up
Notice the nested functions (wrapped_binary_f, timed), this is the main technique that is used to manipulate the arguments of a passed function. The outer function (__call__, timeit) are used to feed the to-be-wrapped function (multiply etc.) and the nested function serves to get the arguments passed to the to-be-wrapped function.

One can also add arguments to the decorator function by way of a class declaration. First the __init__ function is called to create the object of the class arguments passed, the arguments are stored in the object, then the object is called via the __call__  function. The __call__ function can access the arguments from the members of the object.

I have included the complete application here, try it out, improve it, enjoy!

#!/usr/bin/env python
# -*- coding: iso-8859-1 -*-
'''
Command-line interactive script to train mental arithmetic,
also a demonstration of python decorators.
Created on May 22, 2010 and refactored several times after...
'''

def timeit(fun):
  from time import time
  def timed(*args):
    ts = time()
    mistakes, op_symbol = fun(*args)
    te = time()
    time_elapsed = te - ts
    print "Time elapsed:", time_elapsed
    return time_elapsed, mistakes, op_symbol
  return timed

class Query(object):
  def __init__(self, op_symbol):
    self.op_symbol = op_symbol

  def __call__(self, f):
    # NOTE: you can count the function arguments later to try on a n-ary function
    def wrapped_binary_f(*args):
      a,b = tuple(args)
      #print "%s %s %s = ?" % (a, self.op_symbol, b) # infix notation
      print a, self.op_symbol, b, " = ?"
      correct_answer = f(a,b)
      user_answer = None
      mistakes = 0
      while not user_answer or user_answer != correct_answer:
        user_answer = None
        try:
          user_answer = input("answer: ")
          if user_answer != correct_answer:
            mistakes+=1
            print "Please try again... %s" % str(mistakes)
          else:
            print "Correct."
            break
        except SyntaxError:
          mistakes+=1
          print "Please try again... %s" % str(mistakes)
        except NameError:
          mistakes+=1
          print "Please try again... %s" % str(mistakes)
      return mistakes, self.op_symbol
    return wrapped_binary_f
    
@timeit
@Query('*')
def multiply(a, b):
  return a * b
  
@timeit
@Query('-')
def subtract(a, b):
  return a - b
  
@timeit
@Query('+')
def add(a, b):
  return a + b

@timeit
@Query('/')
def divide(a, b):
  return float(a) / float(b)
#  from decimal import Decimal, getcontext
#  from fractions import Fraction # check this shit out, useful if user enters fraction and computer calculates float or Decimal... then check if the same
#  getcontext().prec=2
#  return Decimal(a) / Decimal(b)

def generate_samples_naive(maxdecimals, nsamples):
  from random import choice
  return zip([ choice(xrange(10**maxdecimals)) for _i in xrange(nsamples) ],
             [ choice(xrange(10**maxdecimals)) for _j in xrange(nsamples) ])


def generate_samples_positive(maxdecimals, nsamples):
  """
  The lambda here swaps the values in case subtraction results are negative
  """
  from random import choice
  return map (lambda (x,y): (x,y) if x>=y else (y,x), \
              [ (choice(xrange(10**maxdecimals)), choice(xrange(10**maxdecimals))) \
               for _i in xrange(nsamples) ])

def get_stats(li):
  """
  Tuple of total time spent solving the problems
  and total number of mistakes
  """
  return sum([ t for t,_,_ in li ]), \
    sum([m for _,m,_ in li])

def get_detailed_stats(li):
  """
  Total number of mistakes per operator type,
  total number of time spent per operator type
  """
  d = {}
  for op_symbol in ['*','-','+','/']:
    d[op_symbol] = (sum([ t for t,_,o in li if o == op_symbol ]),
      sum([ m for _,m,o in li if o == op_symbol ]))
  return d

def show_stats(results):
  (total_time, total_mistakes) = get_stats(results)
  print "Total solved:", len(results)
  print "Total time elapsed:", total_time
  print "Total mistakes made:", total_mistakes

  print "Detailed results per operator (time, mistakes):" #, get_detailed_stats(results)
  d = get_detailed_stats(results)
  for k in d.keys():
    (time, mistakes) = d[k]
    print k, "%.2f" % (float(time)/total_time), "%.2f" % (float(mistakes)/total_mistakes)
  print "Done!"

def main(av):
  from optparse import OptionParser
  usage = "usage: %prog [options] arg"
  parser = OptionParser(usage)

  # parser.add_option...
#  parser.add_option("-m", "--max-tries", dest='maxtries', type='int', default=3,
#                    help='Number of tries before going to the next example')
  parser.add_option("-n", "--nr-samples", dest="nsamples", type="int", default=10,
    help="Per arithmetic operation train on this number of SAMPLES", metavar="SAMPLES")
#  parser.add_option("--mind", "--min-decimals", dest="maxdecimals", type="int", default=0,
#    help="Define the lowest number to train expressed as the number of DECIMALS", metavar="DECIMALS")
  parser.add_option("--maxd", "--max-decimals", dest="maxdecimals", type="int", default=2,
    help="Define the largest number to train expressed as the number of DECIMALS", metavar="DECIMALS")
#  parser.add_option("-s", "--no-stats", dest="sendstats", action="store_false", default=True,
#    help="Don't send any statistical data to servers, over the internet.")
#  parser.add_option("-a", "--anonymous", dest="anonymous", action="store_true", default=False,
#    help="Don't send credentials or any data revealing user's identity over the internet")
  parser.add_option("--nd", "--no-division", dest="train_division", action="store_false", default=False,
    help="Don't train user on division")
  parser.add_option("--na", "--no-addition", dest="train_addition", action="store_false", default=True,
    help="Don't train user on division")
  parser.add_option("--ns", "--no-subtraction", dest="train_subtraction", action="store_false", default=True,
    help="Don't train user on subtraction")
  parser.add_option("--nm", "--no-multiplication", dest="train_multiplication", action="store_false", default=True,
    help="Don't train user on multiplication")
  parser.add_option("--nh", "--no-history", dest="store_history", action="store_false", default=True,
    help="Don't store user history, mind you, the same training samples might be repeated very often")
  parser.add_option("-o", "--ordered-training", dest="ordered", action="store_false", default=True,
    help="Train in this order: addition, subtraction, multiplication, division")
  parser.add_option("-e", "--e-mail-addr", dest="email_addr", type="string", default="anon@anon.org",
    help="This script will modify itself to reflect the e-mail ADDRESS of the user in question", metavar="ADDRESS")
  parser.add_option("-p","--positive-only", dest="positive_only", action="store_true", default=False,
                    help="The result from the operation on a pair of samples will only be POSITIVE.", metavar="POSITIVE")


  (options, _args) = parser.parse_args(av)

  if options.positive_only:
    samples = generate_samples_positive(options.maxdecimals, options.nsamples)
  else:
    samples = generate_samples_naive(options.maxdecimals, options.nsamples)

  results = []
  if options.ordered:
    if options.train_subtraction:
      for (a,b) in samples:
        results.append(subtract(a,b))

    if options.train_addition:
      for (a,b) in samples:
        results.append(add(a,b))

    if options.train_multiplication:
      for (a,b) in samples:
        results.append(multiply(a,b))

    if options.train_division:
      for (a,b) in samples:
        results.append(divide(a,b))

  else:
    op_options = []
    if options.train_addition:
      op_options.append(add)
    if options.train_multiplication:
      op_options.append(multiply)
    if options.train_subtraction:
      op_options.append(subtract)
    if options.train_divison:
      op_options.append(divide)
    fns = op_options*options.nsamples
    import random
    random.shuffle(fns)
    for (nr,(a,b)) in enumerate(3*samples): # TODO - come division, change to four
      results.append(fns[nr](a,b))

  show_stats(results)


if __name__ == '__main__':
  from sys import argv as av
  main(av)

Update 30-07-2010: I just realized that you could grab this code and put a gui on top quite easily, by replacing the Query class with something with a gui. Done!

No comments:

Post a Comment

Please help to keep this blog clean. Don't litter with spam.