65.9K
CodeProject 正在变化。 阅读更多。
Home

在词云中查看过滤后的推文

starIconstarIconstarIconstarIconstarIcon

5.00/5 (13投票s)

2015 年 2 月 17 日

CPOL

7分钟阅读

viewsIcon

22416

使用几个开源包,我将推文流粘合在一起,并使用力导向图显示词云中的单词命中数。

引言

我最近与 Quantum Ventura 的 CEO Srini(又名 Mike)Vasan 就语义分析的主题进行了交谈,这促成了这个有趣的小项目。该想法是获取 Twitter 流(使用出色的开源 Tweetinvi C# API),并将其输出与词云结合,我实际上已将其实现为 力导向图,起点是 Bradley Smith 在 2010 年博客中写过的代码。我查看了几种词云生成器,但没有一种适合实时更新,但是,力导向图是实时创建动态推文视图的完美方式。

一段(无声)视频最能说明这一点,所以我在这里发布了一个: https://www.youtube.com/watch?v=vEH_1h0jrZY

请等到 10 秒标记处,精彩内容将开始呈现。

此小程序的主要要点是

  • 单词命中数显示不同的字体大小(从 8pt 到 36pt,表示从 1 到 24 的计数)
  • 单词命中数也反映在颜色中,从蓝色(表示 1 次命中)到红色(表示 24 次或更多次命中)
  • 最多显示 100 个单词
  • 为了容纳新单词(一旦达到最大数量),现有命中数为 1 的单词将被随机删除
  • 为了防止超过 1 次命中数的单词饱和,随着时间的推移,所有单词的计数都会缓慢递减

源代码

源代码在 GitHub 上,地址是: https://github.com/cliftonm/TwitterWordCloud-WinForm

使用 Tweetinvi API 访问 Twitter

这非常简单。您需要先在此处 https://apps.twitter.com/ 获取 Twitter 的消费者密钥和消费者密钥。然后,从此处 https://api.twitter.com/oauth/request_token 获取访问令牌和访问令牌密钥。

完成这些之后,就可以通过以下调用在 API 中设置凭据

// Setup your credentials
TwitterCredentials.SetCredentials("Access_Token", "Access_Token_Secret", "Consumer_Key", "Consumer_Secret");

要使此代码在应用程序中生效,您需要将这些密钥放入名为“twitterauth.txt”的文件中,该文件位于 bin\Debug(或 bin\Release)文件夹中。格式应为

[Access Token]
[Access Token Secret]
[Consumer Key]
[Consumer Secret]

例如(虚构数字)

bl6NVMpfD
bxrhfA8v
svdaQ86mNTE
lvGwXzG3MJnN

您从 Twitter 获取的值将长得多。

我读取这四行并使用以下方式初始化凭据

protected void TwitterAuth()
{
  string[] keys = File.ReadAllLines("twitterauth.txt");
  TwitterCredentials.SetCredentials(keys[0], keys[1], keys[2], keys[3]);
}

启动/停止过滤流

代码可优雅地处理流的启动和停止。所谓的优雅,是指如果流存在,我们会关闭当前流,等待指示其已停止的事件,然后启动新流。

/// <summary>
/// If a stream hasn't been started, just start it.
/// If a stream has been started, shut it down, and when it's stopped, start the new stream.
/// </summary>
protected void RestartStream(string keyword)
{
  if (stream != null)
  {
    Clear();
    stream.StreamStopped += (sender, args) => StartStream(keyword);
    stream.StopStream();
  }
  else
  {
    StartStream(keyword);
  }
}

/// <summary>
/// Start a stream, filtering ony the keyword and only English language tweets.
/// </summary>
protected void StartStream(string keyword)
{
  stream = Stream.CreateFilteredStream();
  stream.AddTrack(keyword);
  stream.MatchingTweetReceived += (sender, args) =>
  {
    if (args.Tweet.Language == Language.English)
    {
      UpdateFdg(args.Tweet.Text);
    }
  };

  stream.StartStreamMatchingAllConditionsAsync();
}

/// <summary>
/// User wants to stop the stream.
/// </summary>
protected void OnStop(object sender, EventArgs e)
{
  if (stream != null)
  {
    stream.StreamStopped += (s, args) => stream = null;
    stream.StopStream();
  }
}

/// <summary>
/// Clear the word cloud.
/// </summary>
protected void Clear()
{
  wordNodeMap.ForEach(kvp => kvp.Value.Diagram = null);
  wordNodeMap.Clear();
}

解析推文

推文中需要解析的词性有很多。目前,要排除的单词字典是硬编码的

protected List<string> skipWords = new List<string>(new string[] { "a", "an", "and", "the", "it", ... etc ...

您明白了。

我们还需要删除标点符号(一种相当粗暴的方法)

protected List<string> punctuation = new List<string>(new string[] { ".", ",", ";", "?", "!" });

public static class Extensions
{
  // TODO: This is probably painfully slow.
  public static string StripPunctuation(this string s)
  {
    var sb = new StringBuilder();

    foreach (char c in s)
    {
      if (!char.IsPunctuation(c))
      {
        sb.Append(c);
      }
    }

    return sb.ToString();
  }
}

并过滤掉推文的特定组件以及我们词典中的单词

/// <summary>
/// Return true if the word should be eliminated.
/// The word should be in lowercase!
/// </summary>
protected bool EliminateWord(string word)
{
  bool ret = false;
  int n;

  if (int.TryParse(word, out n))
  {
    ret = true;
  }
  else if (word.StartsWith("#"))
  {
    ret = true;
  }
  else if (word.StartsWith("http"))
  {
    ret = true;
  }
  else
  {
    ret = skipWords.Contains(word);
  }

  return ret;
}

避免饱和和容纳新推文

如前所述,一旦达到 100 个单词的限制,我们就会删除过时的单词以腾出空间容纳新单词

/// <summary>
/// Remove the stalest 1 hit count word from the list -- this is the word that has not been updated the longest.
/// We do this only when the word count exceends MaxWords
/// </summary>
protected void RemoveAStaleWord()
{
  if (wordNodeMap.Count > MaxWords)
  {
    // TODO: Might be more efficient to maintain a sorted list to begin with!
    DateTime now = DateTime.Now;
    KeyValuePair<string, TextNode> tnode = wordNodeMap.Where(w => w.Value.Count==1).
          OrderByDescending(w => (now - w.Value.UpdatedOn).TotalMilliseconds).First();
    // Do not call RemoveNode, as this results in a stack overflow because the property setter has this side effect.
    tnode.Value.Diagram = null; // THIS REMOVES THE NODE FROM THE DIAGRAM. 
    wordNodeMap.Remove(tnode.Key);
    wordTweetMap.Remove(tnode.Key);
  }
}

上述算法仅适用于命中数为 1 的单词。如果我们不这样做,像“奥巴马”这样高流量的流会导致单词永远无法获得关注,因为有海量的推文涌入。通过仅消除最古老的“杂音”,我们可以获得一张关于围绕奥巴马总统关注点的精美词云。

通过根据推文数量(迭代次数)模某个饱和值(目前设置为 20 - 换句话说,每 20 条推文,所有单词计数都会递减)来随时间减少所有单词计数,可以避免饱和。

/// <summary>
/// Prevent saturation by decrementing all word counts every 20 tweets.
/// </summary>
protected void ReduceCounts()
{
  // Every 20 iterations (the default for SaturationCount), decrement the word count on all non-1 count words.
  // This allows us to eventually replace old words no longer comning up in new tweets.
  if (iteration % SaturationCount == 0)
  {
    iteration = 0;
    wordNodeMap.Where(wc => wc.Value.Count > 1).Select(wc => wc.Key).ForEach(w=>wordNodeMap[w].DecrementCount());
  }
}

排队推文

推文是异步接收的,因此我们在将它们添加到队列时加上锁定。

protected void UpdateFdg(string text)
{
  lock (this)
  {
    tweetQueue.Enqueue(text);
  }
}

出队推文

更新 FDG 的整个过程都在应用程序的主线程中完成,特别是在 OnPaint 方法中,该方法每秒被调用 20 次,通过使所有者绘制面板无效来实现。

timer = new System.Windows.Forms.Timer();
timer.Interval = 1000 / 20; // 20 times a second, in milliseconds.
timer.Tick += (sender, args) => pnlCloud.Invalidate(true);

在 Paint 事件处理程序中,我们出队推文,更新图中的节点,执行 FDG 的单个迭代周期,并绘制结果。

pnlCloud.Paint += (sender, args) =>
{
  Graphics gr = args.Graphics;
  gr.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;

  ++paintIteration;

  if (!overrun)
  {
    overrun = true;
    int maxTweets = 20;

    // We assume here that we can parse the data faster than the incoming stream hands it to us.
    // But we put in a safety check to handle only 20 tweets.
    while (tweetQueue.Count > 0 && (--maxTweets > 0))
    {
      string tweet;

      lock (this)
      {
        tweet = tweetQueue.Dequeue();
      }

      SynchronousUpdate(tweet);
    }

    // gr.Clear(Color.White);
    diagram.Iterate(Diagram.DEFAULT_DAMPING, Diagram.DEFAULT_SPRING_LENGTH, Diagram.DEFAULT_MAX_ITERATIONS);
    diagram.Draw(gr, Rectangle.FromLTRB(12, 24, pnlCloud.Width - 12, pnlCloud.Height - 36));
    overrun = false;
  }
  else
  {
    gr.DrawString("overrun", font, brushBlack, new Point(3, 3));
  }
};

我从未见过应用程序发出过载消息,所以我假设一切处理速度足够快,不会出现问题。此外,对传入推文的处理、单词过滤等都可以用单独的线程完成,但为了简单起见,而且因为我用于 FDG 的许多现有代码都需要重构才能更具线程友好性,所以我决定保持简单,并同步完成所有处理。

更新计数器和推文缓冲区

真正起核心作用的函数是 SynchronousUpdate。在这里,我们删除任何标点符号,消除我们不关心的单词,用推文中的任何新单词替换过时的单词,并更新单词命中数。我们还为每个单词记录最多“MaxTweet”条推文,(我将在接下来展示)当鼠标悬停在上面时,您可以看到推文文本。这是该方法

protected void SynchronousUpdate(string tweet)
{
  string[] words = tweet.Split(' ');

  ++iteration;
  ReduceCounts();

  foreach (string w in words)
  {
    string word = w.StripPunctuation();
    string lcword = word.ToLower();
    TextNode node;

    if (!EliminateWord(lcword))
    {
      if (!wordNodeMap.TryGetValue(lcword, out node))
      {
        ++totalWordCount;
        PointF p = rootNode.Location;
        RemoveAStaleWord();
        TextNode n = new TextNode(word, p);
        rootNode.AddChild(n);
        wordNodeMap[lcword] = n;
        wordTweetMap[lcword] = new Queue<string>(new string[] { tweet });
      }
      else
      {
        wordNodeMap[lcword].IncrementCount();
        Queue<string> tweets = wordTweetMap[lcword];

        // Throw away the oldest tweet if we have more than 20 associated with this word.
        if (tweets.Count > MaxTweets)
        {
          tweets.Dequeue();
        }

        tweets.Enqueue(tweet);
      }
    }
  }
}  

鼠标悬停

鼠标悬停由两个事件处理

pnlCloud.MouseMove += OnMouseMove;
pnlCloud.MouseLeave += (sender, args) =>
{
  if (tweetForm != null)
  {
    tweetForm.Close();
    tweetForm=null;
    mouseWord=String.Empty;
  }
};

当鼠标离开所有者绘制面板时,我们会关闭显示推文的窗体,并将所有内容重置为“未显示推文”状态。

当用户将鼠标移到所有者绘制面板上时,我们会检查鼠标坐标是否在显示单词的矩形内。有一些逻辑可以更新现有的推文窗体,或者在未显示窗体时创建一个新的推文窗体。

/// <summary>
/// Display tweets for the word the user is hovering over.
/// If a tweet popup is currently displayed, move popup window until the mouse is over a different word.
/// </summary>
protected void OnMouseMove(object sender, MouseEventArgs args)
{
  var hits = wordNodeMap.Where(w => w.Value.Region.Contains(args.Location));
  Point windowPos = PointToScreen(args.Location);
  windowPos.Offset(50, 70);

  if (hits.Count() > 0)
  {
    string word = hits.First().Key;
    TextNode node = hits.First().Value;

    if (mouseWord == word)
    {
      tweetForm.Location = windowPos;
    }
    else
    {
      if (tweetForm == null)
      {
        tweetForm = new TweetForm();
        tweetForm.Location = windowPos;
        tweetForm.Show();
        tweetForm.TopMost = true;
      }

      // We have a new word.
      tweetForm.tbTweets.Clear();
      ShowTweets(word);
      mouseWord = word;
    }
  }
  else
  {
    // Just move the window.
    if (tweetForm != null)
    {
      tweetForm.Location = windowPos;
      tweetForm.TopMost = true;
    }
  }
}

结果是一个弹出窗口,当用户在所有者绘制面板上移动鼠标时,该窗口会随鼠标移动。

力导向图

如果您查看 Bradley Smith 最初的 FDG 代码,您会注意到我做了一些更改。首先,我不绘制力线,只绘制节点。

foreach (Node node in nodes)
{
  PointF destination = ScalePoint(node.Location, scale);

  Size nodeSize = node.Size;
  RectangleF nodeBounds = new RectangleF(center.X + destination.X - (nodeSize.Width / 2), center.Y + destination.Y - (nodeSize.Height / 2), nodeSize.Width, nodeSize.Height);
  node.DrawNode(graphics, nodeBounds);
}

原始代码也只是绘制点,所以我扩展了 SpotNote 类来绘制文本。

public class TextNode : SpotNode
{
  protected int count;

  public int Count 
  {
    get { return count; }
  }

  public Rectangle Region { get; set; }

  public DateTime CreatedOn { get; set; }
  public DateTime UpdatedOn { get; set; }

  public static Dictionary<int, Font> fontSizeMap = new Dictionary<int, Font>();

  protected string text;

  public TextNode(string text, PointF location)
    : base()
  {
    this.text = text;
    Location = location;
    count = 1;
    CreatedOn = DateTime.Now;
    UpdatedOn = CreatedOn;
  }

  /// <summary>
  /// Update the UpdatedOn timestamp when incrementing the count.
  /// </summary>
  public void IncrementCount()
  {
    ++count;
    UpdatedOn = DateTime.Now;
  }

  /// <summary>
  /// Do NOT update the UpdatedOn timestamp when decrementing the count.
  /// Also, do not allow the count to go 0 or negative.
  /// </summary>
  public void DecrementCount()
  {
    if (count > 1)
    {
      --count;
    }
  }

  public override void DrawNode(Graphics gr, RectangleF bounds)
  {
    // base.DrawNode(gr, bounds);

    Font font;
    int fontSize = Math.Min(8 + Count, 36);

    if (!fontSizeMap.TryGetValue(fontSize, out font))
    {
      font = new Font(FontFamily.GenericSansSerif, fontSize);
      fontSizeMap[fontSize] = font;
    }

    // Create a color based on count, from 1 to a max of 24
    // Count (or count) is the true count. Here we limit the count to be between 1 and 24.
    int count2 = Math.Min(count, 24);

    if (count2 >= twitterWordCloud.AppForm.CountThreshold)
    {
      int blue = 255 * (24 - count2) / 24;
      int red = 255 - blue;
      Brush brush = new SolidBrush(Color.FromArgb(red, 0, blue));

      SizeF strSize = gr.MeasureString(text, font);
      PointF textCenter = PointF.Subtract(bounds.Location, new Size((int)strSize.Width / 2 - 5, (int)strSize.Height / 2 - 5));
      Region = Rectangle.FromLTRB((int)textCenter.X, (int)textCenter.Y, (int)(textCenter.X + strSize.Width), (int)(textCenter.Y + strSize.Height));

      gr.DrawString(text, font, brush, textCenter);

      brush.Dispose();
    }
  }
}

此类还对文本进行着色,并且每个节点(作为唯一的单词)都跟踪命中数以及创建/更新日期。

我还删除了 Bradley 最初实现的 FDG 的异步行为。还移除了停止迭代的检测——图会永远迭代,这从中心点的持续抖动中可以看出。还进行了各种调整以更好地支持添加/删除节点。

结论

这是一个有趣的小项目,可以利用一些出色的现有工作,只需围绕 Twitter 和 FDG 部分编写一些逻辑即可将它们粘合在一起。我发现了一些令人失望的事情:

  1. 绝大多数“新闻性”推文都被转发了,这常常不成比例地扭曲了命中数。
  2. “原创”推文在大多数情况下相当无聊,只是其他推文的改写。
  3. 人们只关注主流事物。您不会发现人们在谈论全球变暖或替代货币。

我还发现了一些有趣的事情:

  1. 您可以发现主题之间的有趣联系。例如,在观看关于“奥巴马”的动态时,我看到了“大鸟”(BigBird),并发现米歇尔·奥巴马正在与芝麻街的大鸟会面。这对第一夫人来说是一件好事!
  2. 我通过这个程序第一次“读到”了西弗吉尼亚州的油罐火车事故,这是通过过滤“石油”并看到“火车”和“脱轨”等关键词具有很高的命中数的结果。
  3. 对推文进行情绪分析是绝对可能的——有许多单次命中的单词可以传达情绪:“愤怒”、“快乐”、“害怕”、“失望”等等。
  4. 仅仅因为媒体对某事大肆宣传,例如苹果公司涉足电动汽车市场,但这条推文的数量却不存在,这让我得出结论,大多数人对这一新闻事件持“关我什么事?”的态度。

还有一些其他有趣的事情可以做,例如在地图上绘制推文,按地理位置过滤推文,扩展用于“和”与“或”过滤轨迹的过滤机制等等。这个小程序确实只是触及了可能性的表面。

© . All rights reserved.