Статьи

Что я узнал о визуализации данных от Хилари Мэйсон из Bit.ly


Мне повезло, что я смог посетить конференцию Strangeloop этого года (
http://thestrangeloop.com/ ). Хилари Мейсон, экстраординарный ученый, выступила с вступительным словом «Машинное обучение: история любви». Как только она сказала, что нам понадобится немного математики, чтобы пройти презентацию, я поняла, что это будет хорошо. После здорового фона на неудачных попыток машинного обучения через двадцатого века она попала в статистику Байесовских , а затем связанные с этой спиной к своей работе на
bit.ly .

Именно тогда я решил, что целью моих выходных было заставить ее взломать что-нибудь, что угодно, связанное со сбором данных со мной. Проверьте ее в Твиттере @hmason или на ее сайте @ http://www.hilarymason.com/

Милостиво, она согласилась, и мы установили время и место. В итоге около десяти человек взломали около часа в маленьком кафе здесь, в Сент-Луисе. Я опубликовал окончательный продукт здесь: http://github.com/jcbozonier/Strangeloop-Data-Visualization

и Хилари проводит здесь визуализацию:

Это фон, и вот что из этого вышло для меня.

Ответы просты, задавать правильные вопросы сложно


Я занимался самоанализом данных в течение нескольких месяцев в свободное время, и это может сбивать с толку, зная, что я делаю правильно или неправильно.
Это не похоже на программирование, где я могу сказать, есть ли у меня правильный ответ … это более или менее просто я думаю, что ответ кажется правильным. Это действительно сложно для меня.

Собравшись с Хилари, я надеялся получить представление о ее профессиональном рабочем процессе, о том, какими инструментами она пользуется, а также о ее общем подходе и мышлении для ответа на заданный вопрос с ее фу-фью.

Вопрос, над которым мы в конечном итоге решили поработать, был: «Как выглядит социальная сеть Strangeloop в Twitter?» Другими словами, кто с кем разговаривает и сколько? Наша общая ментальная модель для этой проблемы была, по сути, графиком узлов, связанных между собой множеством ненаправленных ребер, которые указывали на то, что эти два человека общались через Twitter. Хилари уже взяла Протовиса вместе с образцом его использования для создания силового макета, поэтому он идеально подходил для ответа на этот вопрос.

Три шага

Сегодня я научился думать об анализе данных как о трех основных шагах или фазах (так как шаги могут стать немного большими).

1. Получить данные — получить данные. В любой удобной форме просто соберите все данные, которые вам нужны, и поместите их на диск. Не волнуйтесь о том, как это красиво и аккуратно.

2. Сократите это — Теперь вы можете взять эту массу данных и начать думать о том, какие их части вы можете использовать. Фаза обрезки — это ваш шанс обрезать данные и немного их сфокусировать. Здесь вы устраняете все аспекты данных, кроме тех, которые вы хотите визуализировать.

3. Гламур — вот где вы выясняете, что вам нужно сделать, чтобы получить ваши данные в визуализируемой форме.

1. Получение данных из Twitter

Чтобы получить наши данные, я написал скрипт, который использовал поисковый API Twitter для загрузки всех твитов, содержащих хеш-тег #strangeloop. Так как данные разбиты на страницы, мой код должен был пройти около 15 страниц, пока он не исчерпал записи Твиттера.

Это код. Это довольно просто, но эффективно.

require 'net/http'

pages_remain = true
number = 1
file_containing_tweets = 'strangeloop_tweets.json'

while(pages_remain)
  open(file_containing_tweets, 'a') { |f|
    Net::HTTP.start("search.twitter.com") { |http|
      response = http.get("/search.json?q=%23strangeloop&rpp=100&page=#{number}")
      
      if response.body == '{"error":"page parameter out of range"}'
        pages_remain = false
      else
        f.puts response
        number += 1
      end
    }
  }
end

Там могут быть ошибки или угловые случаи, и это нормально. Ничего из этого не является кодом, который я бы тестировал, пока не стало очевидно, что я должен. Здесь основная задача — получить данные, и в этом случае, по крайней мере, это двоичный результат. Легко узнать, что какая-то часть этого кода пошла не так. Кроме того, я должен быть в состоянии работать достаточно быстро, чтобы я мог оставаться в потоке проблемы под рукой. Я просто хакнул в Твиттере, пытаясь получить нужные данные в файл на диске. Если я должен сделать это вручную, это нормально.

2. Обрезка данных, чтобы соответствовать моей ментальной модели

Я решил загрузить данные в формате JSON, потому что предполагал, что это будет довольно простой формат для интеграции. Теперь, когда Ruby 1.9 поставляется с модулем JSON из коробки, это было полностью! Ну … в значительной степени.


После того, как я загрузил все данные, я вручную массировал каждый из 15 объектов результатов JSON, чтобы оставить только их твиты и ни один из метаданных, окружающих поиск.
После этого у меня был файл, содержащий 1400-1500 объектов твита JSON в массиве JSON.
 

Теперь во время нашей групповой сессии я фактически не писал эту часть решения.
На самом деле это был Дэвид Джойнер (следите за ним в Twitter как @djoyner), и он доставил конечный результат Хилари в формате CSV через Python. Я перекодировал это здесь, потому что в коде, который мы написали для создания данных, которые мы визуализировали, была ошибка, и мне нужен был способ восстановить данные, как только ошибка была исправлена. Так как у меня не было его сценария Python, я просто решил переписать то, что он сделал.
 

Отсюда я просто попытался загрузить данные в Ruby через модуль JSON.
Я загружаю сохраненный JSON с диска с помощью следующего кода:
require 'json'

def get_file_as_string(filename)
  data = ''
  f = File.open(filename, "r")
  f.each_line do |line|
    data += line
  end

  return data
end

def get_strangeloop_tweets
  text_file_containing_tweets = 'formatted_tweets.json'
  raw_json_text = get_file_as_string text_file_containing_tweets
  tweets = JSON.parse(raw_json_text)
  
  return tweets
end

Мой подход снова был очень хакерским. Сделайте немного ruby-скрипта таким образом, чтобы я мог убедиться, что он работал через командную строку, повторить, добавив еще один или два шага и повторив. Это похоже на TDD, но гораздо менее продуманно, просто взламывая и чувствуя себя в обход проблемного пространства.

3. Glamming It Up For Protovis

To recap, so far you’ve got me getting the data downloaded into a parseable form, this other guy loading that from disk, and then he also did the original work on pulling the data into a set of undirected edges of people talking to one another. I also rewrote this for lack of his code and for lack of Hilary’s code converting his data into something Protovis could use. In order to make the graph really interesting we also decided to add up the number of times a given edge was used which you’ll see being computed in this:
Edge = Struct.new(:from, :to)

def get_tweep_connections_from tweets
  tweep_edges = {}
  tweets.each{ |tweet|
    tweep = tweet['from_user']
    to_nodes = extract_all_tweeps_from tweet
    
    if to_nodes.length > 0
      to_nodes.each{ |node|
        raise "node is blank!!" if node == ''
        edge_a = Edge.new(tweep, node)
        edge_b = Edge.new(node, tweep)

        if tweep_edges.has_key? edge_a
          tweep_edges[edge_a] += 1
        elsif tweep_edges.has_key? edge_b
          tweep_edges[edge_b] += 1
        else
          tweep_edges[edge_a] = 1
        end
      }
    end
  }
  
  return tweep_edges
end

David Joyner was also kind enough to send me his original Python code that essentially does the same thing:

import json, re

RE_MENTION = re.compile(r'@(\w+)')

f = open('formatted_tweets.json')
tweets = json.load(f)
f.close()

graph = {}

for tweet in tweets:
    from_user = tweet['from_user']
    for m in RE_MENTION.finditer(tweet['text']):
        to_user = m.group(0)[1:]

        pair1 = (from_user, to_user)
        pair2 = (to_user, from_user)

        if pair1 in graph:
            graph[pair1] += 1
        elif pair2 in graph:
            graph[pair2] += 1
        else:
            graph[pair1] = 1

for key, value in graph.items():
    print "%s, %s, %d" % (key[0], key[1], value)

The thought was that the more active a person was on Twitter, the more they influenced the network. This could cause someone who was really chatty to get over-emphasized in the visualization but in our case it worked out well.

So, we had all of this data but it wasn’t in the form that Protovis needed to show our awesome visualization. Hilary figured this out by downloading a sample project from their project’s website. The data needed to be put in this form:

var miserables = {
  nodes:[
    {nodeName:"Myriel", group:1},
    {nodeName:"Napoleon", group:1},
    {nodeName:"Mlle. Baptistine", group:1},
    {nodeName:"Mme. Magloire", group:1},
    {nodeName:"Countess de Lo", group:1},
    {nodeName:"Geborand", group:1},
    {nodeName:"Champtercier", group:1},
    {nodeName:"Cravatte", group:1},
    {nodeName:"Count", group:1},
    {nodeName:"Old Man", group:1}
  ],
  links:[
    {source:1, target:0, value:1},
    {source:2, target:0, value:8},
    {source:3, target:0, value:10},
    {source:3, target:2, value:6},
    {source:4, target:0, value:1},
    {source:5, target:0, value:1},
    {source:6, target:0, value:1},
    {source:7, target:0, value:1},
    {source:8, target:0, value:2},
    {source:9, target:0, value:1}
  ]
};

If you scroll through that a ways you’ll eventually see some data that looks like this:

{source:72, target:27, value:1},
Nice eh? Those numbers are basically saying draw a line from the node at index 72 of our list of nodes to the node at index 27. That complicated things a bit but Hilary got through it with some code I imagine wasn’t too dramatically different from this:
def create_protovis_data_from tweeps, tweep_edges
  counter = 0
  tweep_index_lookup = {}

  File.open('strangeloop_words.js', 'w'){|file|
    file.puts 'var miserables = {'
    file.puts 'nodes:['
    
    tweeps.each{|tweep|
      tweep_index_lookup[tweep] = counter
      file.puts "{nodeName:\"#{tweep}\", group:1}, //#{tweep_index_lookup[tweep]}"
      counter += 1
    }
    
    file.puts '],'
    file.puts 'links:['
    
    tweep_edges.each{ |edge, strength|
      from_tweep = edge[:from]
      to_tweep = edge[:to]
      
      raise "bad to tweep!!" if not tweep_index_lookup.include? to_tweep
      raise "bad to tweep!!" if not tweep_index_lookup.include? from_tweep
      
      from_index = tweep_index_lookup[from_tweep]
      to_index = tweep_index_lookup[to_tweep]
      
      file.puts "{source:#{from_index}, target:#{to_index}, value: #{(2)**strength}},"
    }
    
    file.puts ']};'
  }
end

I just basically create a hash where I store the index number for each Twitter user’s name and then look it up when I’m generating that portion of the file.

Biggest Take Away: Baby Steps

There was definitely a fair amount of work here and without all of the team work we wouldn’t have been able to get this done in the 45 minutes it took us. Part of the team work was just figuring out what components of work we had in front of us. The three steps I laid out in this article are how I saw us tackling the problem and there were many other much more iterative steps I left out.
 
When I do more data analysis in the future I plan to just work it through piece by piece and not get overwhelmed by all of the different components that will need to come together in the end.

The Other Biggest Take Away: Get Data At Any Cost Necessary

It’s easy as a programmer for me to get bogged down in thoughts of «quality». Even Hilary was apologizing for the extremely hacked together code she had written. Ultimately though t really doesn’t matter here. The code will not be ran continuously and hell it may never even be ran again! If the code falls apart and blows up, I can quickly rewrite it. I’m my own customer in this sense. I can tolerate errors and I can fix them on the fly. When I’m exploring a problem space the most important thing for me is to reduce the friction of my thought process. If I think best hacking together code then awesome. Once I can get my data I’m done. I don’t care about robustness… I just need it to work right now.
 
I’m harping on this point because it’s such a dramatic shift from the way I see production code for my day job. Code I write for work needs to be understood by a whole team, solid against unconsidered use cases, reliable, etc. Code I write to get me data really quick, I just need the data.
 
While Hilary is a pythonista, at one point I remember her commenting on programming language choice and saying something to the effect of «It doesn’t matter, they all work well.» She was so calm about it… it was almost zen like. After having so many passionate talks regarding programming languages with other programmers it was very refreshing to interact with someone who had a definite preference but was able to keep her eye on the prize… the data and more importantly the answers that the data held.

Next Steps

I’d like to work on a way to tell which of the people I follow on Twitter are valuable and which I should stop following. Essentially a classifier I guess. On top of that I’d like to write another one to recommend people I should follow based on their similarity to other people I do follow (and who are valuable)… We’ll see. I’ve got another project that desperately needs my time right now. If you happen to write this though or know of anyone who has, let me know!