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





5.00/5 (13投票s)
使用几个开源包,我将推文流粘合在一起,并使用力导向图显示词云中的单词命中数。
引言
我最近与 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 部分编写一些逻辑即可将它们粘合在一起。我发现了一些令人失望的事情:
- 绝大多数“新闻性”推文都被转发了,这常常不成比例地扭曲了命中数。
- “原创”推文在大多数情况下相当无聊,只是其他推文的改写。
- 人们只关注主流事物。您不会发现人们在谈论全球变暖或替代货币。
我还发现了一些有趣的事情:
- 您可以发现主题之间的有趣联系。例如,在观看关于“奥巴马”的动态时,我看到了“大鸟”(BigBird),并发现米歇尔·奥巴马正在与芝麻街的大鸟会面。这对第一夫人来说是一件好事!
- 我通过这个程序第一次“读到”了西弗吉尼亚州的油罐火车事故,这是通过过滤“石油”并看到“火车”和“脱轨”等关键词具有很高的命中数的结果。
- 对推文进行情绪分析是绝对可能的——有许多单次命中的单词可以传达情绪:“愤怒”、“快乐”、“害怕”、“失望”等等。
- 仅仅因为媒体对某事大肆宣传,例如苹果公司涉足电动汽车市场,但这条推文的数量却不存在,这让我得出结论,大多数人对这一新闻事件持“关我什么事?”的态度。
还有一些其他有趣的事情可以做,例如在地图上绘制推文,按地理位置过滤推文,扩展用于“和”与“或”过滤轨迹的过滤机制等等。这个小程序确实只是触及了可能性的表面。